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>
93 lines
2.4 KiB
JavaScript
93 lines
2.4 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Verifies the server (and its kernel child) shut down when the MCP host closes
|
|
// stdin — the way a stdio host signals exit. Run after `npm run build`.
|
|
import { spawn, execSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const entry = path.join(here, '..', 'dist', 'index.js');
|
|
|
|
const server = spawn(process.execPath, [entry], {
|
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
cwd: path.join(here, '..'),
|
|
});
|
|
|
|
const send = (obj) => server.stdin.write(JSON.stringify(obj) + '\n');
|
|
const kernelChildren = () => {
|
|
try {
|
|
return execSync(`pgrep -P ${server.pid}`, { encoding: 'utf8' })
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
let failures = 0;
|
|
const check = (label, cond, detail) => {
|
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'lifecycle', version: '1' },
|
|
},
|
|
});
|
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: { name: 'node_repl', arguments: { code: 'nodeRepl.write("up");' } },
|
|
});
|
|
|
|
await new Promise((resolve) => {
|
|
let buf = '';
|
|
server.stdout.on('data', (c) => {
|
|
buf += c.toString();
|
|
if (buf.includes('"id":2')) resolve();
|
|
});
|
|
});
|
|
|
|
const before = kernelChildren();
|
|
check('kernel child spawned', before.length > 0, `pids=${before.join(',')}`);
|
|
|
|
// Close stdin — this is how a stdio MCP host signals shutdown.
|
|
server.stdin.end();
|
|
|
|
const exited = await new Promise((resolve) => {
|
|
const timer = setTimeout(() => resolve(false), 8000);
|
|
server.once('exit', () => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
});
|
|
});
|
|
check('server exits on stdin EOF', exited);
|
|
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
const stillAlive = before.filter((pid) => {
|
|
try {
|
|
process.kill(Number(pid), 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
check('kernel child reaped', stillAlive.length === 0, `alive=${stillAlive.join(',') || 'none'}`);
|
|
|
|
if (!exited) server.kill('SIGKILL');
|
|
console.log(failures === 0 ? '\nLIFECYCLE SMOKE PASSED' : `\n${failures} FAILED`);
|
|
process.exit(failures === 0 ? 0 : 1);
|