mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-24 16:16:13 +00:00
* feat(core): add persistent Node REPL runtime * fix(core): align Node REPL phase-one contract * fix(core): align Node REPL module compatibility * fix(core): remove unused Node REPL host broker * refactor(node-repl)!: deliver as a standalone MCP server, revert core tools Replaces the three built-in `packages/core` node_repl tools with a self-contained MCP server package, `@qwen-code/node-repl-mcp`. Why --- Issue #9333 triage accepted the runtime only "for exploration" and gated it on an open maintainer decision: built-in core tool vs MCP-server-first. Reversing OpenAI Codex 0.149.0 settled the shape — it ships code mode as a standalone host with an in-process fallback, and its real-Node `js_repl` (not the restricted V8 `exec`) is the analogue this roadmap needs, because stage 3 (#9335) imports cua-driver's N-API addons, which only real Node can load. Delivering out-of-core also keeps a security-relevant subsystem out of the maintainer-gated `packages/core` until its value is proven. What changed ------------ - New `packages/node-repl`: the kernel, module loader, cell transform, protocol and kernel manager, ported and now dependency-free (local `debug-log`/`win-path`/`tokenizer` replace core utils; `output-adapter` emits MCP content blocks in place of `result-converter`). - Reverted every `packages/core` change, the ~11 packaging files that existed only to ship the runtime assets into six distribution layouts, and the two design/plan docs describing the core delivery. - Deleted the trusted-package/sha256 layer: it was empty and unreachable in production (`module-loader.mjs` 882 -> 483 lines). - Wired the package into `scripts/build.js` and the root `vitest.config.ts`. Net effect: `packages/core` is untouched relative to main; the tool is opt-in via `mcpServers` instead of registered unconditionally for every user. Correctness fixes made while porting ------------------------------------ - Stack line numbers were wrong and drifted with binding count (source line 4 reported as 87). The prelude is now one physical line and the cell compiles with `lineOffset: -1`. - Top-level `var` nested in blocks/try/switch/loops was silently dropped; collection now walks the statement subtree, pruning at function boundaries. Verified against real Node for 16 constructs. - A binding named `nodeRepl` permanently broke the output channel; such cells are now rejected. Ordinary globals stay shadowable, matching plain Node. - Errors thrown by imported modules lost their class, `code` and stack. - A throwing frame handler could discard buffered protocol frames. - A hoisted `var` assigned before a throw is now kept, as Node does. - Unhandled rejections settling after a cell no longer vanish. - Live sandbox timers are capped, so one runaway loop cannot saturate the session's event loop. - Image MIME types are matched case-insensitively on both input paths. - Binding sort no longer depends on host locale collation. - The published bin lacked a shebang and mis-detected its entry point, and the server plus its kernel leaked on every host disconnect. Tests: 146 across 14 files, including a compiled N-API addon fixture, 100 consecutive cells, 10 concurrent isolated kernels, stack-line fidelity, and hoisting semantics. Three smoke scripts cover the adapter, the MCP wire and process lifecycle; the packed tarball was installed into a clean project and driven end to end. Refs #9333 * fix(node-repl): pin zod to the hoisted 3.x so the workspace build type-checks `packages/node-repl` declared `zod ^4.1.13`, so `npm ci` installed a nested zod 4.4.3 for it while `@modelcontextprotocol/sdk` resolved the hoisted zod 3.25.76. Two zod type identities in one compilation made every `registerTool` input schema unassignable (`Type 'ZodString' is not assignable to type 'AnySchema'`), failing `npm run build` for this workspace — and with it the install step of every CI job that builds workspaces. The SDK accepts `^3.25 || ^4.0`, so pin the hoisted 3.x line and drop the now-stale nested lockfile entry. One deduped zod, no behaviour change. This only reproduced with a lockfile-driven install; the local tree had no nested copy, which is why the build passed locally and failed in CI. * fix(lint): lint package .mjs node scripts with the node env The eslint "scripts we run with node" override matched `packages/*/scripts/**/*.js` but not `.mjs`, nor a package-root `build.mjs`. `packages/node-repl` is `type: module`, so its `build.mjs` and `scripts/*.mjs` were linted as browser code and `eslint .` failed with `'process' is not defined` / `'console' is not defined` / `'setTimeout' is not defined`. The pre-commit hook only lints `*.{js,jsx,ts,tsx}`, so `.mjs` files are not checked locally — this only surfaced in the root CI lint step. Add `packages/*/scripts/**/*.mjs` and `packages/*/build.mjs` to the override, matching the existing `.js` entries. Generic, additive, no behaviour change. * fix(node-repl): address round-3 review findings (resolution, error identity, image bound) Triaged the bot's round-3 critical findings against the ported code and fixed the three that were genuine defects here; each has a regression test. - R3-5 (resolution): a symlinked `<cwd>/node_modules` — the norm under pnpm, monorepo hoisting and shared CI caches — was silently dropped, so the documented zero-config `await import('pkg')` failed with "cannot resolve from 0 module roots" while plain Node resolved it. The implicit cwd root was applying a re-link self-comparison that only makes sense for registered roots (which carry a registration-time canonical baseline). Follow the symlink for the implicit root, but skip it entirely when the cwd node_modules is itself a registered root, so the registered root's revocation guard is not undermined. - R3-3 (error identity): an error thrown by a host builtin inside imported code (e.g. `fs.readFileSync` → ENOENT) was rewrapped message-only, dropping `code`/`errno`/`syscall`/`cause`/`stack` — so `catch (e) { if (e.code === 'ENOENT') }`, a ubiquitous Node idiom, silently took the wrong branch. Carry those fields onto the realm-wrapped error. - R3-6 (image bound): image-frame `mimeType` was unbounded — only `data.length` counted against the raw image budget — so a malformed/forged frame could retain a huge string that also got interpolated into a notice and tokenized. Bound the MIME at frame ingestion (a real image MIME is a few dozen chars). - R3-4 is by design (the runtime is not a security boundary), but the tool description recommended `createRequire` without noting it is not subject to the process denial or module-root containment the import path enforces; the description now says so. - R3-1 / R3-2 (the `.mjs` lint failure) were already fixed in an earlier commit. Suite: 148 tests (added symlinked-cwd resolution and host-builtin error-code regressions). The symlink fix initially defeated the registered-root revocation test; the registered-root deferral above resolves both. * docs(node-repl): note top-level function/class re-declaration needs a fresh name Round-3 review R3-12/R3-33: re-declaring an existing top-level function or class in a later cell is a link-time SyntaxError (they persist as `let`, which the prelude re-declares), whereas a plain Node REPL accepts it. A correct fix needs declaration-kind tracking the manager does not carry today; until then the tool description's rerun guidance ('prefer var') is corrected, since a function cannot be made rerunnable via var. --------- Co-authored-by: Claude <noreply@anthropic.com>
79 lines
3 KiB
JavaScript
79 lines
3 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Standalone smoke test: drives the built kernel manager directly (no MCP wire)
|
|
// to prove persistence, dynamic import + package singleton, image output, and
|
|
// reset. Run: node scripts/smoke.mjs (from packages/node-repl, after build)
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { NodeReplKernelManager } from '../dist/kernel-manager.js';
|
|
import { NodeReplSecurityPolicy } from '../dist/security-policy.js';
|
|
import { convertOutcomeToMcpResult } from '../dist/output-adapter.js';
|
|
|
|
const manager = new NodeReplKernelManager({
|
|
cwd: process.cwd(),
|
|
homeDir: os.homedir(),
|
|
tmpRootDir: path.join(os.tmpdir(), 'qwen-node-repl-smoke'),
|
|
policy: NodeReplSecurityPolicy.default(),
|
|
readableRoots: [process.cwd()],
|
|
});
|
|
|
|
const run = async (code) => {
|
|
const outcome = await manager.exec({ code, timeoutMs: 30_000 });
|
|
const mcp = convertOutcomeToMcpResult(outcome);
|
|
return { status: outcome.status, mcp };
|
|
};
|
|
|
|
let failures = 0;
|
|
const check = (label, cond, detail) => {
|
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
try {
|
|
// 1. Persistence across cells.
|
|
await run('const a = 21;');
|
|
const r2 = await run('nodeRepl.write(String(a * 2));');
|
|
const text2 = r2.mcp.content.find((b) => b.type === 'text')?.text ?? '';
|
|
check('binding persists across cells', text2.includes('42'), text2.trim());
|
|
|
|
// 2. Dynamic import of a builtin + closure persistence.
|
|
await run(
|
|
"const os2 = await import('node:os'); const host = os2.hostname();",
|
|
);
|
|
const r3 = await run('nodeRepl.write(typeof host);');
|
|
const text3 = r3.mcp.content.find((b) => b.type === 'text')?.text ?? '';
|
|
check('dynamic import binding persists', text3.includes('string'), text3.trim());
|
|
|
|
// 3. Image output (1x1 PNG).
|
|
const png =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
|
const r4 = await run(
|
|
`await nodeRepl.emitImage({ bytes: Uint8Array.from(atob(${JSON.stringify(png)}), c => c.charCodeAt(0)), mimeType: 'image/png' });`,
|
|
);
|
|
const hasImage = r4.mcp.content.some((b) => b.type === 'image');
|
|
check('emitImage produces an image block', hasImage);
|
|
|
|
// 4. Reset discards bindings.
|
|
await manager.reset();
|
|
const r5 = await run('nodeRepl.write(typeof a);');
|
|
const text5 = r5.mcp.content.find((b) => b.type === 'text')?.text ?? '';
|
|
check('reset discards bindings', text5.includes('undefined'), text5.trim());
|
|
|
|
// 5. Error surfaces as isError with status note.
|
|
const r6 = await run('throw new Error("boom");');
|
|
const errText = r6.mcp.content.find((b) => b.type === 'text')?.text ?? '';
|
|
check(
|
|
'runtime error -> isError + message',
|
|
r6.mcp.isError === true && errText.includes('boom'),
|
|
errText.trim(),
|
|
);
|
|
} finally {
|
|
manager.dispose();
|
|
}
|
|
|
|
console.log(failures === 0 ? '\nALL SMOKE CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`);
|
|
process.exit(failures === 0 ? 0 : 1);
|