qwen-code/packages/node-repl/src/node-repl.lines.test.ts
顾盼 be891657f7
refactor(node-repl)!: deliver the persistent Node REPL as a standalone MCP server (#9499)
* 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>
2026-08-23 14:20:39 +00:00

122 lines
3.9 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import { NodeReplKernelManager } from './kernel-manager.js';
import { NodeReplSecurityPolicy } from './security-policy.js';
// Regression: reported stack line numbers must match the source the caller
// wrote, and must NOT drift as session bindings accumulate. The generated
// prelude is one physical line (compensated by lineOffset: -1) and the
// per-statement snapshot markers are newline-free.
const managers: NodeReplKernelManager[] = [];
function makeManager(): NodeReplKernelManager {
const manager = new NodeReplKernelManager({
cwd: process.cwd(),
homeDir: os.homedir(),
tmpRootDir: fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-line-')),
policy: NodeReplSecurityPolicy.default(),
readableRoots: [process.cwd()],
});
managers.push(manager);
return manager;
}
afterEach(() => {
while (managers.length > 0) managers.pop()?.dispose();
});
/** First `<cellfile>:<line>:<col>` frame from the error stack. */
function reportedLine(stack: string | undefined): number | null {
if (!stack) return null;
const match = stack.match(/_cell_[^\s:]*:(\d+):\d+/);
return match?.[1] ? Number(match[1]) : null;
}
describe('cell stack line fidelity', () => {
it('reports the correct line with no prior bindings', async () => {
const manager = makeManager();
// throw is on source line 1
const outcome = await manager.exec({
code: 'throw new Error("boom");',
timeoutMs: 30_000,
});
expect(outcome.status).toBe('error');
expect(reportedLine(outcome.error?.stack)).toBe(1);
});
it('reports the correct line further down a cell', async () => {
const manager = makeManager();
const code = ['const a = 1;', 'const b = 2;', 'throw new Error("deep");'];
const outcome = await manager.exec({
code: code.join('\n'),
timeoutMs: 30_000,
});
expect(outcome.status).toBe('error');
// throw is on source line 3
expect(reportedLine(outcome.error?.stack)).toBe(3);
});
it('does not drift as accumulated bindings grow', async () => {
const manager = makeManager();
// Build up many live bindings across several cells.
for (let i = 0; i < 12; i++) {
const ok = await manager.exec({
code: `const v${i} = ${i};`,
timeoutMs: 30_000,
});
expect(ok.status).toBe('ok');
}
// Same shape as the earlier test: throw on source line 3.
const outcome = await manager.exec({
code: ['const x = 1;', 'const y = 2;', 'throw new Error("late");'].join(
'\n',
),
timeoutMs: 30_000,
});
expect(outcome.status).toBe('error');
expect(reportedLine(outcome.error?.stack)).toBe(3);
});
it('keeps imported-module line numbers correct (no cell offset leakage)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-lm-'));
const modulePath = path.join(dir, 'thrower.mjs');
// The throw sits on line 4 of the imported file.
fs.writeFileSync(
modulePath,
[
'// line 1',
'// line 2',
'export function boom() {',
' throw new Error("inner");',
'}',
].join('\n'),
);
const manager = makeManager();
try {
const outcome = await manager.exec({
code: [
`const m = await import(${JSON.stringify(modulePath)});`,
'm.boom();',
].join('\n'),
timeoutMs: 30_000,
});
expect(outcome.status).toBe('error');
const stack = outcome.error?.stack ?? '';
// The imported module has no prelude and no lineOffset.
expect(stack).toMatch(/thrower\.mjs:4:/);
// The cell call-site is on cell source line 2.
expect(reportedLine(stack)).toBe(2);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});