191 lines
12 KiB
JavaScript
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();
|
|
}
|
|
}
|