import { randomBytes, randomInt, createHash } from 'node:crypto'; import { mkdir, readFile, writeFile, chmod, chown, readdir, lstat, statfs } 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 disk = await statfs(base); if (Number(disk.bavail) * Number(disk.bsize) < 512 * 1024 * 1024) throw new Error('服务器可用磁盘空间不足,未启动 Agent 任务'); 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 { const disk = await statfs(base); if (Number(disk.bavail) * Number(disk.bsize) < 256 * 1024 * 1024) throw new Error('disk reserve'); let bytes = 0; async function scan(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); let info; try { info = await lstat(path); } catch (error) { if (error.code === 'ENOENT') continue; throw error; } 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(); } }