191 lines
12 KiB
JavaScript

import { randomBytes, randomInt, createHash } from 'node:crypto';
import { mkdir, readFile, writeFile, chmod, chown, readdir, lstat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { createRunBroker } from './broker.mjs';
import { sandboxCommand } from './sandbox.mjs';
export const UPSTREAM_COMMIT = '0a1fc56d279fd1001f78387bd81aec1c26767411';
const MAX_OUTPUT = 8 * 1024 * 1024;
function rpc(message, result) { return { jsonrpc: '2.0', id: message.id, result }; }
function publicOutput(output) {
const text = JSON.stringify(output);
return text.length <= 128_000 ? output : { summary: text.slice(0, 24_000), truncated: true,
createdNodeIds: output.createdNodeIds, updatedNodeIds: output.updatedNodeIds, status: output.status };
}
async function ownTree(path, uid) {
const info = await lstat(path); if (info.isSymbolicLink()) throw new Error('沙箱工作区不允许宿主符号链接');
await chown(path, uid, uid);
if (info.isDirectory()) for (const entry of await readdir(path)) await ownTree(join(path, entry), uid);
}
async function ensurePrivate(path) { await mkdir(path, { recursive: true, mode: 0o700 }); await chmod(path, 0o700); }
export async function verifyRuntime(runtimeDir) {
const manifest = JSON.parse(await readFile(join(runtimeDir, 'manifest.json'), 'utf8'));
if (manifest.commit !== UPSTREAM_COMMIT || typeof manifest.sha256 !== 'string') throw new Error('原生 ZCode 核心构建与固定版本不一致');
const digest = createHash('sha256').update(await readFile(join(runtimeDir, 'zcode.cjs'))).digest('hex');
if (digest !== manifest.sha256) throw new Error('原生 ZCode 核心完整性检查失败');
return manifest;
}
// Lifecycle adapter only: reasoning, compaction, scheduling and tool execution
// decisions belong to the unmodified upstream CLI, not a local imitation loop.
export async function runNativeZcode(options) {
if (!/^[a-zA-Z0-9-]{1,80}$/.test(options.runId)) throw new Error('无效的 Agent 任务标识');
const manifest = await verifyRuntime(options.runtimeDir);
const base = resolve(options.stateDir); await mkdir(base, { recursive: true, mode: 0o711 }); await chmod(base, 0o711);
const root = join(base, options.runId); await ensurePrivate(root);
const home = join(root, 'home'); const workspace = join(root, 'workspace'); const temp = join(root, 'tmp');
for (const path of [home, workspace, temp, join(home, '.zcode/v2'), join(home, '.zcode/cli')]) await ensurePrivate(path);
const token = randomBytes(32).toString('base64url');
const linux = process.platform === 'linux';
let uid;
const metadataPath = join(root, 'sandbox.json');
try { uid = JSON.parse(await readFile(metadataPath, 'utf8')).uid; }
catch (error) { if (error.code !== 'ENOENT') throw error; }
if (linux && !uid) {
// Allocate a distinct numeric UID per durable task; no shared nobody user.
for (let attempt = 0; attempt < 10; attempt++) {
const candidate = randomInt(200000, 1200000);
try { await mkdir(join(base, `uid-${candidate}`), { mode: 0o700 }); uid = candidate; break; }
catch (error) { if (error.code !== 'EEXIST') throw error; }
}
if (!uid) throw new Error('无法分配独立 Agent 运行身份');
}
await writeFile(metadataPath, JSON.stringify({ uid, commit: manifest.commit }), { mode: 0o600 });
const tools = new Map(options.tools.map(tool => [tool.name, tool]));
const inFlight = new Map();
const broker = await createRunBroker({ token,
...(linux ? { socketPath: join(root, 'broker.sock') } : {}),
model: options.model,
mcp: async (message, signal) => {
const params = message.params || {};
switch (message.method) {
case 'initialize': return rpc(message, { protocolVersion: params.protocolVersion || '2025-03-26',
capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'yedir-canvas', version: '1.0.0' } });
case 'ping': return rpc(message, {});
case 'tools/list': return rpc(message, { tools: options.tools.map(tool => ({ name: tool.name,
description: tool.description, inputSchema: tool.inputSchema,
annotations: { readOnlyHint: tool.readOnly, destructiveHint: false, openWorldHint: !tool.readOnly },
_meta: { timeoutMs: 20 * 60_000 } })) });
case 'tools/call': {
if (!tools.has(params.name) || !params.arguments || typeof params.arguments !== 'object' || Array.isArray(params.arguments))
return { jsonrpc: '2.0', id: message.id, error: { code: -32602, message: '无效的已授权画布工具或参数' } };
const span = params._meta?.span_id || params._meta?.['com.zcode/request-context']?.span_id;
// Upstream MCP propagates the native tool span. Preserve it for
// exactly-once billing/audit across transport retries and recovery.
const callId = typeof span === 'string' && /^[\w:.-]{1,180}$/.test(span)
? span : `${options.attemptId || options.runId}:${message.id}`;
const signature = JSON.stringify([params.name, params.arguments]);
const previous = inFlight.get(callId);
if (previous && previous.signature !== signature) throw new Error('同一工具调用标识不能更改参数');
if (!previous) inFlight.set(callId, { signature, promise: options.execute(params.name, params.arguments, callId, signal) });
try {
const output = publicOutput(await inFlight.get(callId).promise);
return rpc(message, { content: [{ type: 'text', text: JSON.stringify(output) }], isError: false });
} catch (error) {
const detail = options.formatToolError?.(error) || '工具执行未成功,请读取任务状态与具体错误;不得盲目重复提交收费操作。';
return rpc(message, { content: [{ type: 'text', text: detail }], isError: true });
}
}
default:
if (message.method.startsWith('notifications/')) return undefined;
return { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: '此任务网关不提供该方法' } };
}
},
});
try {
const baseUrl = linux ? 'http://127.0.0.1:32119/v1' : `${broker.url}/v1`;
const personal = { schemaVersion: 1, config: {
providerConfigRules: { providerRules: [{ providerId: 'canvas', providerName: 'Canvas Task Gateway', config: {
group: 'standard-personal', access: { type: 'api-key', apiKey: token },
api: { type: options.apiType || 'openai-chat-completions', baseUrl }, personalModelIds: [options.modelId],
} }] }, modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
defaultModelSelection: { providerId: 'canvas', modelId: options.modelId },
} };
await writeFile(join(home, '.zcode/v2/provider_config.json'), JSON.stringify(personal), { mode: 0o600 });
const bridge = linux ? '/runtime/bridge.mjs' : join(options.runtimeDir, 'bridge.mjs');
const nativeNode = linux ? '/runtime/bin/node' : options.nodePath;
const configuration = { features: { mcp: true, subagent: true, memory: true, skill: true },
plugins: { enabled: true }, skills: { enabled: true }, hooks: { enabled: false },
memory: { use: true }, ui: { locale: 'zh-CN' }, modelStream: { idleTimeoutMs: 120000 },
mcp: { servers: { canvas: { type: 'stdio', command: nativeNode, args: [bridge, 'mcp'],
enabled: true, timeoutMs: 20 * 60_000 } } } };
await writeFile(join(home, '.zcode/cli/config.json'), JSON.stringify(configuration), { mode: 0o600 });
await writeFile(join(root, 'passwd'), `agent:x:${uid || 1000}:${uid || 1000}:Canvas Agent:/home/agent:/bin/bash\n`, { mode: 0o644 });
await writeFile(join(root, 'group'), `agent:x:${uid || 1000}:\n`, { mode: 0o644 });
// Instructions and data are kept separate. The workspace has no host source,
// .env, SSH keys, database credentials, or another run's session files.
await writeFile(join(workspace, 'AGENTS.md'), options.instructions, { mode: 0o600 });
await writeFile(join(workspace, 'canvas-context.json'), JSON.stringify(options.context || {}), { mode: 0o600 });
if (linux) { await ownTree(root, uid); }
const sandboxWorkspace = linux ? '/workspace' : workspace;
const cliArgs = ['--prompt', options.prompt, '--cwd', sandboxWorkspace, '--mode', 'yolo',
'--output-format', 'stream-json', '--no-color', '--workflow-mode', 'onDemand'];
if (options.sessionId) {
if (!/^[a-zA-Z0-9_-]{1,180}$/.test(options.sessionId)) throw new Error('原生会话标识无效');
cliArgs.push('--resume', options.sessionId);
}
const command = await sandboxCommand({ runtimeDir: options.runtimeDir, root, token, brokerUrl: broker.url,
uid, runId: options.runId, nodePath: options.nodePath, cliArgs });
await options.onSandbox?.({ kind: command.kind, commit: manifest.commit });
const child = spawn(command.command, command.args, { cwd: command.cwd, env: command.env,
detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
let size = 0; let result; let stderr = ''; let stopReason;
const stop = reason => {
stopReason ||= reason;
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* already exited */ }
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 3000); timer.unref();
};
const cancelled = () => stop('cancelled'); options.signal?.addEventListener('abort', cancelled, { once: true });
if (options.signal?.aborted) cancelled();
const deadline = setTimeout(() => stop('timeout'), options.timeoutMs || 20 * 60_000);
const diskCheck = setInterval(async () => {
try {
let bytes = 0;
async function scan(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name); const info = await lstat(path);
if (info.isSymbolicLink()) continue;
if (info.isDirectory()) await scan(path); else bytes += info.size;
if (bytes > 512 * 1024 * 1024) throw new Error('disk budget');
}
}
await scan(home); await scan(workspace); await scan(temp);
} catch { stop('disk_budget'); }
}, 5000); diskCheck.unref();
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-6000); });
const completion = new Promise((resolve, reject) => {
child.once('error', reject); child.once('close', (code, signal) => resolve({ code, signal }));
});
completion.catch(() => undefined);
try {
for await (const line of createInterface({ input: child.stdout, crlfDelay: Infinity })) {
size += Buffer.byteLength(line); if (size > MAX_OUTPUT) { stop('output_budget'); continue; }
if (!line.trim()) continue;
let event; try { event = JSON.parse(line); } catch { throw new Error('原生 ZCode 未返回有效事件协议'); }
if (event.type === 'result') result = event;
await options.onEvent?.(event);
}
const exit = await completion;
if (stopReason || exit.code !== 0 || !result?.sessionId) {
const error = new Error(stopReason === 'cancelled' ? 'Agent 任务已停止' : '原生 ZCode 任务中断,已保留执行记录');
error.code = stopReason || 'ZCODE_EXIT'; error.exitCode = exit.code;
// Bounded diagnostics stay on the host and are never returned to models/UI.
await writeFile(join(root, 'last-exit.json'), JSON.stringify({ ...exit, stopReason, stderr }), { mode: 0o600 });
throw error;
}
return result;
} finally {
clearTimeout(deadline); clearInterval(diskCheck); options.signal?.removeEventListener('abort', cancelled);
if (child.exitCode === null) stop('cleanup');
}
} finally {
// A paid business request may have been accepted before cancellation. Keep
// its host-side promise/checkpoint alive, even after the sandbox exits.
await Promise.allSettled([...inFlight.values()].map(entry => entry.promise));
await broker.close();
}
}