diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b47c1bc632..cb3b5596b4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1784,6 +1784,13 @@ jobs: QWEN_DEFAULT_AUTH_TYPE: '' run: |- node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" + # Not a duplicate of the pre-checkout autocrlf step: HOME and + # USERPROFILE are redirected for this step, so git reads a different + # global config here — and that step is gated on self-hosted runners, + # so on the hosted windows-2022 fallback it never runs. Deleting this + # line silently returns CRLF conversion to every git operation the + # suites perform, and no test notices. + git config --global core.autocrlf false # ENOSPC has failed test steps mid-suite while the host looks # healthy afterwards — a transient spike, likely /tmp inodes or a # tmpfs cap. Sample the routed temp filesystem every 10s so the diff --git a/.github/workflows/windows-runner-smoke.yml b/.github/workflows/windows-runner-smoke.yml index 519ed3f6803..bb5c0da8f0e 100644 --- a/.github/workflows/windows-runner-smoke.yml +++ b/.github/workflows/windows-runner-smoke.yml @@ -117,4 +117,10 @@ jobs: QWEN_DEFAULT_AUTH_TYPE: '' run: |- node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" + # Not a duplicate of the pre-checkout autocrlf step: HOME and + # USERPROFILE are redirected for this step, so git reads the empty + # profile created just above rather than the one that step wrote. + # Deleting this line silently returns CRLF conversion to every git + # operation the suites perform, and no test notices. + git config --global core.autocrlf false npm run test:ci diff --git a/integrations/external-context-mem0/src/auto-recall.integration.test.ts b/integrations/external-context-mem0/src/auto-recall.integration.test.ts index 07b3428eac7..cb4af4e395f 100644 --- a/integrations/external-context-mem0/src/auto-recall.integration.test.ts +++ b/integrations/external-context-mem0/src/auto-recall.integration.test.ts @@ -141,6 +141,7 @@ describe('Mem0 Auto Recall local provider', () => { const server = createTcpServer((socket) => { connected = true; sockets.push(socket); + socket.on('error', () => undefined); }); servers.push(server); await new Promise((resolve) => diff --git a/package-lock.json b/package-lock.json index 96112873fb6..f983d0d6eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "qwen": "scripts/cli-entry.js" }, "devDependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "@types/chrome": "^0.1.32", "@types/jsdom": "^28.0.3", "@types/marked": "^5.0.2", @@ -1532,12 +1534,30 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", - "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", "dependencies": { "tslib": "^2.4.0" } @@ -3715,6 +3735,26 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "license": "MIT", + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3928,6 +3968,48 @@ "node": ">= 10" } }, + "node_modules/@node-rs/jieba-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-wasm32-wasi/-/jieba-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-dCwPgGljuxCCqNK7VmOWGTgs/aFX+A9oeCerm0KY3GWAhZMwQwe5gjB/j05ypY37TB6Og3JtJQ/ptBIYe0NT+Q==", + "license": "MIT", + "dependencies": { + "@emnapi/core": "2.0.0-alpha.4", + "@emnapi/runtime": "2.0.0-alpha.4", + "@napi-rs/wasm-runtime": "~1.2.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@node-rs/jieba-wasm32-wasi/node_modules/@emnapi/core": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.4.tgz", + "integrity": "sha512-KjoUR6mNvjT+z5bKMy+a5QDI+kTNTiMZi6uPKzWuWjwZGNa0UPwIVduTfeBRdf6v8Ws5fmHDh7Usf15w/ANfkg==", + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@node-rs/jieba-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.4.tgz", + "integrity": "sha512-Wy1TQ99obRP3Ah7cf/OOtK1zD9t3pYTWz+G/SpAwTFMhG5hoMdlvfzBpYoHUigS/izu3q+VhYMKhJd7Ii/trpg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@node-rs/jieba-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@node-rs/jieba-win32-arm64-msvc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@node-rs/jieba-win32-arm64-msvc/-/jieba-win32-arm64-msvc-2.0.2.tgz", @@ -7530,6 +7612,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/archiver": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", @@ -17182,6 +17273,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17203,6 +17295,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17224,6 +17317,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17245,6 +17339,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17266,6 +17361,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17287,6 +17383,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17308,6 +17405,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17329,6 +17427,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17350,6 +17449,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17371,6 +17471,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -17392,6 +17493,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -29041,6 +29143,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@node-rs/jieba": "2.0.2", + "@node-rs/jieba-wasm32-wasi": "2.0.2", "@qwen-code/sdk": "file:../sdk-typescript", "ansi-regex": "^6.2.2", "prompts": "^2.4.2", diff --git a/package.json b/package.json index 8a52389a7d9..8bc600cdc64 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,8 @@ "LICENSE" ], "devDependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "@types/chrome": "^0.1.32", "@types/jsdom": "^28.0.3", "@types/marked": "^5.0.2", diff --git a/packages/acp-bridge/src/sessionAttachments.test.ts b/packages/acp-bridge/src/sessionAttachments.test.ts index 9ea2a8081be..dd83d6cdb51 100644 --- a/packages/acp-bridge/src/sessionAttachments.test.ts +++ b/packages/acp-bridge/src/sessionAttachments.test.ts @@ -1456,7 +1456,9 @@ describe('SessionAttachmentStore', () => { ); await fs.mkdir(directory, { recursive: true }); await fs.writeFile(path.join(directory, 'notes.txt'), 'a'); - await fs.writeFile(path.join(directory, 'bad?.txt'), 'b'); + if (process.platform !== 'win32') { + await fs.writeFile(path.join(directory, 'bad?.txt'), 'b'); + } await fs.mkdir(path.join(directory, 'sub')); expect((await store.list()).map((item) => item.attachmentId)).toEqual([ diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 89dc8445780..d0d5d926064 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -7364,7 +7364,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); }); - it('rejects a standalone directory identity replaced during Config relocation', async () => { + it('rejects a standalone directory identity replaced during Config relocation', async (ctx) => { await withEmptyTrustedFolders(async (directory) => { const settings = makeSessionSettings({ mcpServers: {} }); const { agent, agentPromise, sessionId, innerConfig } = @@ -7373,6 +7373,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const target = path.join(root, getConversationDirectoryName(sessionId)); await fs.mkdir(target, { recursive: true, mode: 0o700 }); const expectation = await managedConversationExpectation(root, sessionId); + if (expectation.child.inode === 0) { + mockConnectionState.resolve(); + await agentPromise; + ctx.skip(); + return; + } innerConfig.getSessionSourceType.mockReturnValue('standalone'); innerConfig.relocateWorkingDirectory.mockImplementation(async () => { const previous = `${expectation.child.canonicalPath}.previous`; @@ -7973,7 +7979,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 'trusted-capability', ); try { - await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const workspaceCwd = path.resolve('/tmp'); + await agent.newSession({ cwd: workspaceCwd, mcpServers: [] }); const input = { title: 'Docs', locator: { type: 'workspace_file', workspacePath: 'README.md' }, @@ -7982,7 +7989,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { sessionId, input, }); - innerConfig.getTargetDir.mockReturnValue('/tmp/subdir'); + innerConfig.getTargetDir.mockReturnValue( + path.join(workspaceCwd, 'subdir'), + ); const after = await agent.extMethod('qwen/session/sources/upsert', { sessionId, input, @@ -7992,7 +8001,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { change: 'unchanged', source: before['source'], }); - expect(after['source']).toMatchObject({ workspaceCwd: '/tmp' }); + expect(after['source']).toMatchObject({ workspaceCwd }); } finally { mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/commands/review/cleanup.test.ts b/packages/cli/src/commands/review/cleanup.test.ts index 4e6973811aa..e87fb558d0a 100644 --- a/packages/cli/src/commands/review/cleanup.test.ts +++ b/packages/cli/src/commands/review/cleanup.test.ts @@ -335,7 +335,11 @@ describe('runCleanup', () => { }, ); - it('reads process.cwd() ONCE per run — the entry capture — and never downstream (R30-6)', () => { + it('reads process.cwd() ONCE per run — the entry capture — and never downstream (R30-6)', (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } // The mid-run half of the deleted-cwd class: the entry guard covers a cwd // already gone, but `scratchWorktreesOf` used to read the cwd again later // (`resolve(worktree)` against it, `redirectedAncestor`'s default stop), diff --git a/packages/cli/src/commands/review/findings.test.ts b/packages/cli/src/commands/review/findings.test.ts index 2b0df132024..9498305e0f3 100644 --- a/packages/cli/src/commands/review/findings.test.ts +++ b/packages/cli/src/commands/review/findings.test.ts @@ -14,6 +14,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -1444,7 +1445,7 @@ describe('findings (command boundary)', () => { ).toThrow(/--to-anchors must not be a symlink/); }); - it('refuses a --to-anchors hardlinked to a sibling file', () => { + it('refuses a --to-anchors hardlinked to a sibling file', (ctx) => { // realpathSync never resolves hard links: two names of one inode compare // as different path strings, so a string-identity guard admits them and // both writes hit the same file — the exact destruction the guard exists @@ -1455,6 +1456,11 @@ describe('findings (command boundary)', () => { writeFileSync(out, JSON.stringify([base])); // a previous run's artifact const anchors = join(dir, 'anchors.json'); linkSync(out, anchors); + const inode = statSync(out).ino; + if (!Number.isSafeInteger(inode) || inode <= 0) { + ctx.skip(); + return; + } expect(() => (findingsCommand.handler as (a: unknown) => void)({ input, diff --git a/packages/cli/src/commands/review/lib/git.integration.test.ts b/packages/cli/src/commands/review/lib/git.integration.test.ts index 0a90031a4c9..10cf7b0a135 100644 --- a/packages/cli/src/commands/review/lib/git.integration.test.ts +++ b/packages/cli/src/commands/review/lib/git.integration.test.ts @@ -448,7 +448,11 @@ describe('releaseWorktree', () => { }, ); - it('degrades through the result — never throws — when the cwd is deleted mid-release', () => { + it('degrades through the result — never throws — when the cwd is deleted mid-release', (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } // The never-throws contract starts before the first git call: `resolve` // of the RELATIVE path production callers pass reads the cwd, and so does // `redirectedAncestor`'s default `stopAt = process.cwd()` — both threw @@ -629,7 +633,11 @@ describe('gitProbe — the exit status the anchor taxonomy rests on', () => { } }); - it('answers "could not be run" when the process cwd no longer exists', () => { + it('answers "could not be run" when the process cwd no longer exists', (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } // The launch-dir pre-check reads `process.cwd()`, which throws ENOENT once // the directory is gone. Left outside the try it took the whole probe with // it — and `releaseWorktree`'s documented never-throws contract, cleanup's diff --git a/packages/cli/src/commands/review/lib/same-file.test.ts b/packages/cli/src/commands/review/lib/same-file.test.ts index 2599b3d6f9c..5813a661a77 100644 --- a/packages/cli/src/commands/review/lib/same-file.test.ts +++ b/packages/cli/src/commands/review/lib/same-file.test.ts @@ -90,7 +90,8 @@ describe('isSameFile', () => { // Hard-link identity rides dev/ino; on volumes that expose no inode // numbers (ino 0) the comparison degrades to canonical spellings by // design and cannot see through a hard link. - if (Number(statSync(original).ino) === 0) { + const inode = statSync(original).ino; + if (!Number.isSafeInteger(inode) || inode <= 0) { ctx.skip(); return; } diff --git a/packages/cli/src/commands/review/lib/worktree.test.ts b/packages/cli/src/commands/review/lib/worktree.test.ts index 9111534a9ca..ed7eebf015c 100644 --- a/packages/cli/src/commands/review/lib/worktree.test.ts +++ b/packages/cli/src/commands/review/lib/worktree.test.ts @@ -223,7 +223,7 @@ describe('worktreeResidue', () => { const marker = join(repo, 'PWNED-included'); writeFileSync( join(repo, 'innocuous.cfg'), - `[filter "evil"]\n\tclean = touch ${marker} && cat\n`, + `[filter "evil"]\n\tclean = touch ${marker.replaceAll('\\', '/')} && cat\n`, ); gitRepo('config', 'include.path', '../innocuous.cfg'); mkdirSync(join(repo, '.git', 'info'), { recursive: true }); @@ -2000,7 +2000,7 @@ describe('discardWorktree', () => { ]); // The shape the reverse scan exists for: the tree's own pointer is // unreadable, so `adminDirOf` answers null and `worktree remove` fails. - writeFileSync(join(second, '.git'), 'not a gitfile\n'); + overwriteGitfile(join(second, '.git'), 'not a gitfile\n'); discardWorktree(repo, second); @@ -2104,7 +2104,7 @@ describe('filterCommandsIn — the include walk', () => { writeFileSync(join(elsewhere, 'x.cfg'), '[filter "x"]\n\tclean = cat\n'); writeFileSync( join(dir, 'config'), - `[include]\n\tpath = ${join(elsewhere, 'x.cfg')}\n`, + `[include]\n\tpath = ${join(elsewhere, 'x.cfg').replaceAll('\\', '/')}\n`, ); expect(filterCommandsIn(dir, dir)).toEqual({ filters: ['filter.x.clean'], @@ -2206,13 +2206,29 @@ describe('filterCommandsIn — the include walk', () => { expect(filterBlankEnv([])).toEqual({}); }); - it('localFilterCommands: a discovery that fails is a hit, and a newline in the path does not mis-pair the dirs', () => { + it('localFilterCommands: a discovery that fails is a hit, not an empty screen', () => { // The old wrapper answered `[]` — "no filters" — when rev-parse failed, - // and split one newline-delimited answer for two flags, so a directory - // named with a newline paired the wrong dirs and screened nothing. + // so all four efficacy screens concluded there was nothing to blank and + // authorised worktree operations on an unscreened `.git/config`. This + // branch is platform-free by construction: a failed `git rev-parse` + // answers `[UNRESOLVED_REPO]` on any git, so it stays ungated and must + // run on the Windows lane too. expect(localFilterCommands(dir).join(' ')).toContain( 'could not be resolved', ); + }); + + it('localFilterCommands: a newline in the path does not mis-pair the dirs', (ctx) => { + if (process.platform === 'win32') { + // Win32 cannot create a directory whose name contains a newline, so + // there is no fixture to build. Only this half is Windows-impossible — + // the fail-closed branch above is not, and must not be gated with it. + ctx.skip(); + return; + } + // The old wrapper split one newline-delimited answer for two flags, so a + // directory named with a newline paired the wrong dirs and screened + // nothing. const base = realpathSync(mkdtempSync(join(tmpdir(), 'qwen-filter-nl-'))); try { const repo = join(base, 'a\nb', 'repo'); @@ -2293,7 +2309,11 @@ describe('filterCommandsIn — the include walk', () => { } }); - it('follows an include whose `..` the KERNEL resolves through a symlink, not lexically', () => { + it('follows an include whose `..` the KERNEL resolves through a symlink, not lexically', (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } // `/link` is a symlink, and `include.path = link/../evil.cfg` names a // payload ONE LEVEL ABOVE the link's target. git concatenates and lets the // kernel resolve, so it reads that payload; a lexical collapse — `resolve()` diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index f794829d040..93c01b976c8 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -1001,7 +1001,7 @@ describe('repo-context providers and trust boundary', () => { ); }); - it('rejects plan/out aliases and preserves the plan on artifact failure', () => { + it('rejects plan/out aliases and preserves the plan on artifact failure', (ctx) => { const root = temp(); const worktree = join(root, 'worktree'); mkdirSync(worktree); @@ -1014,6 +1014,11 @@ describe('repo-context providers and trust boundary', () => { const alias = join(root, 'alias.json'); linkSync(planPath, alias); + const inode = statSync(planPath).ino; + if (!Number.isSafeInteger(inode) || inode <= 0) { + ctx.skip(); + return; + } expect(() => runRepoContext({ plan: planPath, worktree, out: alias }, [ { provide: () => context() }, diff --git a/packages/cli/src/commands/review/scratch-tree.test.ts b/packages/cli/src/commands/review/scratch-tree.test.ts index 1bd56b7aad7..72e9b1a183d 100644 --- a/packages/cli/src/commands/review/scratch-tree.test.ts +++ b/packages/cli/src/commands/review/scratch-tree.test.ts @@ -270,7 +270,7 @@ describe('runScratchTree', () => { const pwned = join(repo, 'PWNED-included-smudge'); writeFileSync( join(repo, 'innocuous.cfg'), - `[filter "evil"]\n\tsmudge = touch ${pwned}\n`, + `[filter "evil"]\n\tsmudge = touch ${pwned.replaceAll('\\', '/')}\n`, ); git(worktree, 'config', 'include.path', join(repo, 'innocuous.cfg')); writeFileSync(join(worktree, 'a.ts'), 'dirty\n'); @@ -1510,15 +1510,17 @@ describe('runScratchTree --standalone', () => { const linked = runScratchTree({ worktree, label }); expect(linked.available).toBe(true); expect(statSync(join(linked.path!, '.git')).isFile()).toBe(true); - expect(git(repo, 'worktree', 'list', '--porcelain')).toContain( - linked.path!, - ); + expect( + git(repo, 'worktree', 'list', '--porcelain').replaceAll('\\', '/'), + ).toContain(linked.path!.replaceAll('\\', '/')); const r = run(label); expect(r.available).toBe(true); expect(r.path).toBe(linked.path); expect(statSync(join(r.path!, '.git')).isDirectory()).toBe(true); - expect(git(repo, 'worktree', 'list', '--porcelain')).not.toContain(r.path!); + expect( + git(repo, 'worktree', 'list', '--porcelain').replaceAll('\\', '/'), + ).not.toContain(r.path!.replaceAll('\\', '/')); expect(existsSync(join(repo, '.git', 'worktrees', basename(r.path!)))).toBe( false, ); @@ -1537,9 +1539,9 @@ describe('runScratchTree --standalone', () => { expect(linked.reused).toBe(false); expect(linked.standalone).toBe(false); expect(statSync(join(linked.path!, '.git')).isFile()).toBe(true); - expect(git(repo, 'worktree', 'list', '--porcelain')).toContain( - linked.path!, - ); + expect( + git(repo, 'worktree', 'list', '--porcelain').replaceAll('\\', '/'), + ).toContain(linked.path!.replaceAll('\\', '/')); }); it('rebuilds over a symlink at the path without following it', () => { diff --git a/packages/cli/src/commands/review/test-efficacy.test.ts b/packages/cli/src/commands/review/test-efficacy.test.ts index 855c2f635e2..0abb6efe941 100644 --- a/packages/cli/src/commands/review/test-efficacy.test.ts +++ b/packages/cli/src/commands/review/test-efficacy.test.ts @@ -716,7 +716,7 @@ describe('restoreProbeTreeTracked, through runOneMutant', () => { appendFileSync( join(dir, '.git', 'config'), `[filter "evil"]\n\tsmudge = echo ${'x'.repeat(1200000)}\n` + - `\tsmudge = touch ${canary}\n`, + `\tsmudge = touch ${canary.replaceAll('\\', '/')}\n`, ); writeFileSync(join(dir, 'a.ts'), 'dirtied by a previous run\n'); @@ -829,7 +829,11 @@ describe('restoreProbeTreeTracked, through runOneMutant', () => { } }); - it('REFUSES an include whose `..` the kernel resolves through a symlink', () => { + it('REFUSES an include whose `..` the kernel resolves through a symlink', (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } // The other half of the test above, and the one that makes the `dangling` // bucket safe to drop anything at all. `/.git/link` is a symlink and // `include.path = link/../evil.cfg` names a payload one level ABOVE the diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 5e134e44d8c..2c0d33f6377 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -170,21 +170,30 @@ describe('Live conversation workspace root', () => { } }); - it('revalidates both canonical identity and the configured path', async () => { + it('revalidates both canonical identity and the configured path', async (ctx) => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); const identity = await workspace.getRoot(); + // Both revalidations return the SAME object and skip the inode whenever + // `inodeVerifiable` is false on both sides, so they hold on a host with + // unverifiable inodes — gate them away and a mutant returning a fresh + // object goes undetected there. Only the swap below needs a real inode. expect(await workspace.revalidate()).toBe(identity); expect(await revalidateConversationRoot(identity)).toBe(identity); + if (!identity.inodeVerifiable) { + ctx.skip(); + return; + } + await rename(identity.configuredRoot, `${identity.configuredRoot}-old`); await mkdir(identity.configuredRoot, { mode: 0o700 }); await expect(workspace.revalidate()).rejects.toThrow(/identity changed/); }); - it('preserves Live filesystem errors while standalone keeps root scope', async () => { + it('preserves Live filesystem errors while standalone keeps root scope', async (ctx) => { const liveHome = await tempHome(); const liveWorkspace = new ConversationWorkspace({ homeDir: liveHome }); const liveRoot = await liveWorkspace.getRoot(); @@ -198,6 +207,10 @@ describe('Live conversation workspace root', () => { homeDir: standaloneHome, }); const standaloneRoot = await standaloneWorkspace.getRoot(); + if (!standaloneRoot.inodeVerifiable) { + ctx.skip(); + return; + } await rename( standaloneRoot.configuredRoot, `${standaloneRoot.configuredRoot}-old`, @@ -704,10 +717,14 @@ describe('Live conversation workspace root', () => { expect(inspected.error.reason).toBe('unexpected_identity'); }); - it('rejects a replacement directory during deletion staging', async () => { + it('rejects a replacement directory during deletion staging', async (ctx) => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); const prepared = await workspace.prepareStandaloneDirectory('standalone'); + if (prepared.identity.inode === 0) { + ctx.skip(); + return; + } await rename( prepared.identity.canonicalPath, `${prepared.identity.canonicalPath}.preserved`, diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts index 8972634de28..32446c6b146 100644 --- a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts @@ -141,7 +141,7 @@ describe('StandaloneDeletionJournal', () => { } }); - it.each(['base', 'state'] as const)( + it.skipIf(process.platform === 'win32').each(['base', 'state'] as const)( 'rejects a complete private replacement %s tree on every operation', async (parent) => { const root = await workspace.getRoot(); @@ -258,7 +258,11 @@ describe('StandaloneDeletionJournal', () => { }); }); - it('rejects journal directory replacement during phase sync', async () => { + it('rejects journal directory replacement during phase sync', async (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } const root = await workspace.getRoot(); const record = await makeRecord('prepared'); const journalDirectory = path.dirname(journalPath('prepared')); @@ -302,7 +306,11 @@ describe('StandaloneDeletionJournal', () => { } }); - it('retains a same-session fence until clear durability is confirmed', async () => { + it('retains a same-session fence until clear durability is confirmed', async (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } const root = await workspace.getRoot(); const prepared = await makeRecord('prepared'); await journal.writePrepared(prepared, root); @@ -421,11 +429,16 @@ describe('StandaloneDeletionJournal', () => { await expect(journal.read(SESSION_ID, root)).resolves.toBeUndefined(); }); - it('rejects journal directory replacement while clearing phases', async () => { + it('rejects journal directory replacement while clearing phases', async (ctx) => { const root = await workspace.getRoot(); const prepared = await makeRecord('prepared'); await journal.writePrepared(prepared, root); const journalDirectory = path.dirname(journalPath('prepared')); + const journalStats = await fs.lstat(journalDirectory); + if (!Number.isSafeInteger(journalStats.ino) || journalStats.ino <= 0) { + ctx.skip(); + return; + } const originalDirectory = `${journalDirectory}.original`; const originalOpen = openMock.getMockImplementation(); if (!originalOpen) throw new Error('expected fs.open implementation'); diff --git a/packages/cli/src/serve/fs/workspace-file-system.ts b/packages/cli/src/serve/fs/workspace-file-system.ts index 6798c7bbd2a..78e8d51e9f6 100644 --- a/packages/cli/src/serve/fs/workspace-file-system.ts +++ b/packages/cli/src/serve/fs/workspace-file-system.ts @@ -587,13 +587,13 @@ async function resolveSameHostToolWriteTarget(input: string): Promise { let leaf: Awaited>; try { - leaf = await fsp.lstat(input); + leaf = await fsp.lstat(input, { bigint: true }); } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { throw err; } const parent = await fsp.realpath(path.dirname(input)); - const parentStat = await fsp.lstat(parent); + const parentStat = await fsp.lstat(parent, { bigint: true }); if (!parentStat.isDirectory()) { throw new FsError( 'parse_error', @@ -614,7 +614,7 @@ async function resolveSameHostToolWriteTarget(input: string): Promise { throw new FsError('parse_error', `path is not a regular file: ${input}`); } const canonical = await fsp.realpath(input); - const canonicalStat = await fsp.lstat(canonical); + const canonicalStat = await fsp.lstat(canonical, { bigint: true }); if (!canonicalStat.isFile()) { throw new FsError( 'parse_error', diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 24febdfa298..31a937a15bb 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -13258,7 +13258,7 @@ describe('runQwenServe channel worker supervisor', () => { } }); - it('reports the wildcard bound address, not the inet_aton spelling', async () => { + it('reports what the socket bound, not what the operator typed', async () => { mockRemoteQuickstart.print.mockClear(); vi.stubEnv('QWEN_SERVER_TOKEN', 'env-token-aton-pin'); tmpDir = fs.realpathSync( @@ -13269,7 +13269,7 @@ describe('runQwenServe channel worker supervisor', () => { started = await runQwenServe( { port: 0, - hostname: '0', + hostname: '0.0.0.0', mode: 'http-bridge', serveWebShell: false, workspace: tmpDir, @@ -13278,10 +13278,21 @@ describe('runQwenServe channel worker supervisor', () => { ); expect(mockRemoteQuickstart.print).toHaveBeenCalledOnce(); const arg = mockRemoteQuickstart.print.mock.calls[0][0]; - // The operator spelling and the socket address differ here, so this - // pins that boot reports what the socket bound, not what was typed. - expect(arg.bind).toBe('0'); - expect(arg.boundAddress).toBe('0.0.0.0'); + const socket = started.server.address() as { + address: string; + port: number; + }; + // This case used to bind the inet_aton spelling '0' so the typed value + // and the socket address differed, but Windows answers that with + // `getaddrinfo ENOTFOUND 0`. remote-quickstart.test.ts already pins the + // spelling normalisation ('0', '0.0', '::0', …) platform-independently, + // so what is left to witness here is the port: the operator typed 0, so + // a report echoing its own input would show 0 rather than the ephemeral + // port the listener actually got. + expect(arg.bind).toBe('0.0.0.0'); + expect(arg.boundAddress).toBe(socket.address); + expect(arg.port).toBe(socket.port); + expect(arg.port).not.toBe(0); } finally { vi.unstubAllEnvs(); await started?.close(); diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 372907f699f..e07c83f45fb 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -16,6 +16,7 @@ import { SessionStorageEntryError, SessionWriterConflictError, SessionWriterLostError, + GitWorktreeService, type SessionWriterLease, Storage, getCronFilePath, @@ -2675,27 +2676,27 @@ describe('deleteDaemonSessions worktree cleanup', () => { }); it('does not certify preservation when the checkout may be partially deleted', async () => { - // Make the final rmdir fail after the contents are gone (the parent - // goes read-only): the failure log must not claim the checkout was - // preserved. + // Simulate a removal that deletes the checkout before reporting failure: + // the failure log must not claim the checkout was preserved. const sessionId = '550e8400-e29b-41d4-a716-4466554400b8'; const { service, worktreePath } = setupWorktreeSession(sessionId); - const parent = path.dirname(worktreePath); - fs.chmodSync(parent, 0o500); - try { - const result = await deleteDaemonSessions({ - sessionIds: [sessionId], - service, - bridge: cleanupBridge(), - coordinator: new SessionArchiveCoordinator(), - }); + vi.spyOn( + GitWorktreeService.prototype, + 'removeUserWorktree', + ).mockImplementation(async () => { + fs.rmSync(worktreePath, { recursive: true, force: true }); + return { success: false, error: 'injected removal failure' }; + }); + const result = await deleteDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: cleanupBridge(), + coordinator: new SessionArchiveCoordinator(), + }); - expect(result.removed).toEqual([sessionId]); - expect(warnings()).toContain('may be partially deleted'); - expect(warnings()).not.toContain('preserved checkout'); - } finally { - fs.chmodSync(parent, 0o700); - } + expect(result.removed).toEqual([sessionId]); + expect(warnings()).toContain('may be partially deleted'); + expect(warnings()).not.toContain('preserved checkout'); }); it('keeps the checkout and warns when a sidecar base is not absolute', async () => { diff --git a/packages/cli/src/serve/session-attachments-root.test.ts b/packages/cli/src/serve/session-attachments-root.test.ts index e96dde15984..70bbc819a31 100644 --- a/packages/cli/src/serve/session-attachments-root.test.ts +++ b/packages/cli/src/serve/session-attachments-root.test.ts @@ -128,7 +128,9 @@ describe('session attachment root resolution', () => { }); it('expands a bare tilde in the standalone resolver', () => { - expect(resolveConfiguredSessionAttachmentsRoot('~')).toBe(homedir()); + expect(resolveConfiguredSessionAttachmentsRoot('~')).toBe( + path.resolve(homedir()), + ); }); it('keeps an absolute path unchanged in the standalone resolver', () => { diff --git a/packages/cli/src/services/web-shell-brand.test.ts b/packages/cli/src/services/web-shell-brand.test.ts index 0e595e774e3..6840de489b5 100644 --- a/packages/cli/src/services/web-shell-brand.test.ts +++ b/packages/cli/src/services/web-shell-brand.test.ts @@ -999,9 +999,14 @@ describe('resolveWebShellBrand', () => { // lets a read-only FIFO open succeed. const logoPath = writeLogo(LOGO_SVG); vi.mocked(fs.fstatSync).mockImplementationOnce(((fd: number) => { - const stat = fsActual.fstatSync(fd); - const fake = Object.create(Object.getPrototypeOf(stat)) as fs.Stats; - Object.assign(fake, stat, { ino: stat.ino + 1 }); + // Production stats both sides as BigIntStats and compares with a bare + // `!==`, so a number-backed fake would trip `dev` first and never + // reach the inode this test perturbs. + const stat = fsActual.fstatSync(fd, { bigint: true }); + const fake = Object.create( + Object.getPrototypeOf(stat), + ) as fs.BigIntStats; + Object.assign(fake, stat, { ino: stat.ino + 1n }); return fake; }) as never); const { brand, warnings } = resolveWebShellBrand( @@ -1033,8 +1038,11 @@ describe('resolveWebShellBrand', () => { return; } // Drive the post-swap state: the pre-open lstat reports a regular file, - // so the guards pass and the open hits the FIFO. - const regular = fsActual.lstatSync(writeLogo(LOGO_SVG)); + // so the guards pass and the open hits the FIFO. Production stats both + // sides as BigIntStats and compares with a bare `!==`, so a + // number-backed fake would trip `dev` first and the `!stat.isFile()` + // arm this test exists to drive would never be evaluated. + const regular = fsActual.lstatSync(writeLogo(LOGO_SVG), { bigint: true }); vi.mocked(fs.lstatSync).mockImplementationOnce((() => regular) as never); const { brand, warnings } = resolveWebShellBrand( makeSettings({ user: brandSettings({ logoPath: fifoPath }) }), diff --git a/packages/cli/src/services/web-shell-brand.ts b/packages/cli/src/services/web-shell-brand.ts index 162ff3b7bc4..2cdf57708d0 100644 --- a/packages/cli/src/services/web-shell-brand.ts +++ b/packages/cli/src/services/web-shell-brand.ts @@ -193,9 +193,9 @@ function readBrandLogo( // Refuse non-regular files before opening: on POSIX, opening a FIFO read-only // blocks until a writer connects, which would hang the request. `lstatSync` // rather than `statSync` so a symlinked path soft-fails here too. - let stat: fs.Stats | undefined; + let stat: fs.BigIntStats | undefined; try { - stat = fs.lstatSync(filePath, { throwIfNoEntry: false }); + stat = fs.lstatSync(filePath, { bigint: true, throwIfNoEntry: false }); } catch { return { warnings: [`ui.brand.logoPath is not readable: ${filePath}`] }; } @@ -220,14 +220,14 @@ function readBrandLogo( warnings: [`ui.brand.logoPath must be a regular file: ${filePath}`], }; } - if (stat.nlink > 1) { + if (stat.nlink > 1n) { return { warnings: [ `ui.brand.logoPath must not have multiple hard links (nlink=${stat.nlink}): ${filePath}`, ], }; } - if (stat.size > MAX_BRAND_LOGO_BYTES) { + if (stat.size > BigInt(MAX_BRAND_LOGO_BYTES)) { return { warnings: [ `ui.brand.logoPath exceeds ${MAX_BRAND_LOGO_BYTES} bytes: ${filePath}`, @@ -296,7 +296,7 @@ function readBrandLogo( */ function readRegularFileNoFollow( filePath: string, - expectedStat: fs.Stats, + expectedStat: fs.BigIntStats, ): | { content: string; reason?: undefined } | { content?: undefined; reason: string } { @@ -312,7 +312,7 @@ function readRegularFileNoFollow( fd = fs.openSync(filePath, flags); // Re-verify identity on the FD: if anything changed between the lstat above // and this open, refuse rather than read whatever the FD now points at. - const stat = fs.fstatSync(fd); + const stat = fs.fstatSync(fd, { bigint: true }); if ( stat.dev !== expectedStat.dev || stat.ino !== expectedStat.ino || diff --git a/packages/cli/src/ui/opentui/input-prompt.test.tsx b/packages/cli/src/ui/opentui/input-prompt.test.tsx index 975d8b254dc..bedaba4bfe3 100644 --- a/packages/cli/src/ui/opentui/input-prompt.test.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.test.tsx @@ -1706,7 +1706,11 @@ describe('OpenTuiInputPrompt Windows Tab approval-mode fallback (F-2)', () => { expect(cycles).toBe(0); }); - it('leaves a bare Tab alone off Windows', async () => { + it('leaves a bare Tab alone off Windows', async (ctx) => { + if (process.platform === 'win32') { + ctx.skip(); + return; + } let cycles = 0; renderWithCycle(() => { cycles += 1; diff --git a/packages/cli/src/ui/opentui/opentui-footer.test.tsx b/packages/cli/src/ui/opentui/opentui-footer.test.tsx index 5baa3a6cfa2..17ee74b675a 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.test.tsx @@ -272,7 +272,9 @@ describe('OpenTuiFooter', () => { expect(text).not.toContain('Auto-edit mode'); // ink's AutoAcceptIndicator suffixes the mode with the cycle shortcut, and // the composer now binds it. - expect(text).toContain('auto-accept edits (shift + tab to cycle)'); + expect(text).toContain( + `auto-accept edits (${process.platform === 'win32' ? 'tab' : 'shift + tab'} to cycle)`, + ); rerender( { />, ); expect(container.textContent).toContain( - 'Enter to steer · Ctrl+Q to queue · Auto mode (shift + tab to cycle) ⏳ 2 queued', + `Enter to steer · Ctrl+Q to queue · Auto mode (${process.platform === 'win32' ? 'tab' : 'shift + tab'} to cycle) ⏳ 2 queued`, ); }); diff --git a/packages/core/src/code-mode/host-client.ts b/packages/core/src/code-mode/host-client.ts index d5a1911ebf4..272ad10744b 100644 --- a/packages/core/src/code-mode/host-client.ts +++ b/packages/core/src/code-mode/host-client.ts @@ -8,7 +8,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { CodeModeBindingPlan } from '../tools/code-mode.js'; import { resolveBundleDir } from '../utils/bundlePaths.js'; import { @@ -56,7 +56,7 @@ function hostCommand(): { command: string; args: string[] } { command: process.execPath, args: [ '--import', - require.resolve('tsx'), + pathToFileURL(require.resolve('tsx')).href, path.join(path.dirname(currentFile), 'host.ts'), ], }; diff --git a/packages/core/src/lsp/native-lsp-service.ts b/packages/core/src/lsp/native-lsp-service.ts index 04471689d20..3e5b5a66f97 100644 --- a/packages/core/src/lsp/native-lsp-service.ts +++ b/packages/core/src/lsp/native-lsp-service.ts @@ -779,7 +779,11 @@ export class NativeLspService { try { // Even disk-reading servers need a readable discovery candidate, but // ordinary queries need not read text that cannot be delivered. - fs.accessSync(filePath, fs.constants.R_OK); + if (!this.isUsableWorkspaceSymbolFile(filePath)) { + throw new Error( + 'Workspace symbol warmup candidate is no longer usable.', + ); + } await this.ensureDocumentSynchronized( serverName, handle as LspServerHandle & { connection: LspConnectionInterface }, diff --git a/packages/core/src/services/session-sources.test.ts b/packages/core/src/services/session-sources.test.ts index d0c2aeaee99..251f0021052 100644 --- a/packages/core/src/services/session-sources.test.ts +++ b/packages/core/src/services/session-sources.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; import { SessionSourceService, restoreSessionSources, @@ -21,7 +22,10 @@ const link = (url = 'https://example.com/doc#one') => ({ locator: { type: 'url', url }, }); -function fixture(sessionId = 'session', workspaceCwd = '/workspace') { +function fixture( + sessionId = 'session', + workspaceCwd = path.resolve('/workspace'), +) { let stored: SessionSourcesSnapshot | undefined; const persist = vi.fn(async (snapshot: SessionSourcesSnapshot) => { stored = structuredClone(snapshot); @@ -59,7 +63,7 @@ describe('session sources', () => { change: 'unchanged', }); expect(first.source).toMatchObject({ - workspaceCwd: '/workspace', + workspaceCwd: path.resolve('/workspace'), locator: { workspacePath: 'docs/requirements.md' }, }); const one = await service.upsert(link()); diff --git a/packages/core/src/services/worktreeSessionService.test.ts b/packages/core/src/services/worktreeSessionService.test.ts index 36b2d0824e9..7fa5ffab7f6 100644 --- a/packages/core/src/services/worktreeSessionService.test.ts +++ b/packages/core/src/services/worktreeSessionService.test.ts @@ -114,6 +114,9 @@ describe('readWorktreeSession', () => { }); describe('readWorktreeSessionStrict', () => { + const differentIdentity = (value: number | bigint): number | bigint => + typeof value === 'bigint' ? (value === 1n ? 2n : 1n) : value === 1 ? 2 : 1; + it('distinguishes missing, valid, and malformed sidecars', async () => { await expect(readWorktreeSessionStrict(filePath)).resolves.toEqual({ state: 'missing', @@ -189,9 +192,9 @@ describe('readWorktreeSessionStrict', () => { const statSpy = vi .spyOn(prototype, 'stat') .mockImplementationOnce(async function (this: typeof probe) { - const stats = await originalStat.call(this); + const stats = await originalStat.call(this, { bigint: true }); return Object.assign(stats, { - ino: typeof stats.ino === 'bigint' ? stats.ino + 1n : stats.ino + 1, + ino: differentIdentity(stats.ino), }); }); @@ -215,12 +218,11 @@ describe('readWorktreeSessionStrict', () => { const statSpy = vi .spyOn(prototype, 'stat') .mockImplementation(async function (this: typeof probe) { - const stats = await originalStat.call(this); + const stats = await originalStat.call(this, { bigint: true }); statCalls++; return statCalls === 2 ? Object.assign(stats, { - ino: - typeof stats.ino === 'bigint' ? stats.ino + 1n : stats.ino + 1, + ino: differentIdentity(stats.ino), }) : stats; }); diff --git a/packages/core/src/services/worktreeSessionService.ts b/packages/core/src/services/worktreeSessionService.ts index 3147fe8db03..4ccc74dc98c 100644 --- a/packages/core/src/services/worktreeSessionService.ts +++ b/packages/core/src/services/worktreeSessionService.ts @@ -158,19 +158,22 @@ export async function readWorktreeSessionStrict( nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW ?? 0) | (nodeFs.constants.O_NONBLOCK ?? 0); - const before = await fs.lstat(filePath); + const before = await fs.lstat(filePath, { bigint: true }); observedSidecar = true; - if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) { + if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1n) { return { state: 'invalid', reason: 'unsafe sidecar file type' }; } - if (before.ino === 0 || before.size > WORKTREE_SESSION_SIDECAR_MAX_BYTES) { + if ( + before.ino === 0n || + before.size > BigInt(WORKTREE_SESSION_SIDECAR_MAX_BYTES) + ) { return { state: 'invalid', reason: 'unsafe sidecar size or identity' }; } handle = await fs.open(filePath, flags); - const opened = await handle.stat(); + const opened = await handle.stat({ bigint: true }); if ( !opened.isFile() || - opened.nlink !== 1 || + opened.nlink !== 1n || opened.dev !== before.dev || opened.ino !== before.ino ) { @@ -194,16 +197,16 @@ export async function readWorktreeSessionStrict( if (bytesRead > WORKTREE_SESSION_SIDECAR_MAX_BYTES) { return { state: 'invalid', reason: 'unsafe sidecar size or identity' }; } - const after = await handle.stat(); - const pathStats = await fs.lstat(filePath); + const after = await handle.stat({ bigint: true }); + const pathStats = await fs.lstat(filePath, { bigint: true }); if ( !after.isFile() || - after.nlink !== 1 || - after.size !== bytesRead || + after.nlink !== 1n || + after.size !== BigInt(bytesRead) || after.dev !== opened.dev || after.ino !== opened.ino || !pathStats.isFile() || - pathStats.nlink !== 1 || + pathStats.nlink !== 1n || pathStats.dev !== after.dev || pathStats.ino !== after.ino ) { diff --git a/packages/core/src/tools/artifact/artifact-snapshots.ts b/packages/core/src/tools/artifact/artifact-snapshots.ts index db98d6a7479..1b23860f91d 100644 --- a/packages/core/src/tools/artifact/artifact-snapshots.ts +++ b/packages/core/src/tools/artifact/artifact-snapshots.ts @@ -184,11 +184,10 @@ export async function retainArtifactSnapshot( if (!dir) return; const references = path.join(dir, 'references'); const owner = snapshotReference(sessionId); + const entries = await fs.readdir(references); if ( !operationId && - (await fs.readdir(references)).some( - (entry) => entry === owner || entry.startsWith(`${owner}-`), - ) + entries.some((entry) => entry === owner || entry.startsWith(`${owner}-`)) ) return; await fs.writeFile( diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index 49a696fbd0e..c9518c958f2 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -1914,7 +1914,7 @@ await agent('scan package.json') ); expect(trailer).toContain('tokens: 0 spent (no cap)'); expect(trailer).toContain( - `resume: Workflow({ scriptPath: "${result.scriptPath}", resumeFromRunId: "${runId}" })`, + `resume: Workflow({ scriptPath: ${JSON.stringify(result.scriptPath)}, resumeFromRunId: "${runId}" })`, ); // Named paths are real files, not a format the runtime never wrote. await expect(fs.readFile(result.scriptPath!, 'utf8')).resolves.toBe( @@ -2405,7 +2405,7 @@ await agent('scan package.json') // A resume without the original args still runs — it just misses every // journal key, because the script bakes args into the agent prompts. expect(trailer).toContain( - `resume: Workflow({ scriptPath: "${result.scriptPath}", resumeFromRunId: "`, + `resume: Workflow({ scriptPath: ${JSON.stringify(result.scriptPath)}, resumeFromRunId: "`, ); expect(trailer).toContain('args: {"who":"world"}'); expect(trailer).not.toContain('too large to inline'); diff --git a/packages/core/src/utils/git-branches.test.ts b/packages/core/src/utils/git-branches.test.ts index 7c35c71e2ad..0cf3d59b573 100644 --- a/packages/core/src/utils/git-branches.test.ts +++ b/packages/core/src/utils/git-branches.test.ts @@ -50,6 +50,7 @@ function makeRepo(): string { git(dir, 'config', 'user.name', 'Test'); git(dir, 'config', 'commit.gpgsign', 'false'); git(dir, 'config', 'tag.gpgsign', 'false'); + git(dir, 'config', 'core.autocrlf', 'false'); git(dir, 'config', 'core.hooksPath', path.join(dir, '.git', 'hooks')); fs.writeFileSync(path.join(dir, 'a.txt'), 'one\n'); git(dir, 'add', '.'); @@ -90,6 +91,7 @@ function makeUpstream(): { dir: string; clone: string } { git(clone, 'config', 'user.email', 'other@example.com'); git(clone, 'config', 'user.name', 'Other'); git(clone, 'config', 'commit.gpgsign', 'false'); + git(clone, 'config', 'core.autocrlf', 'false'); return { dir, clone }; } diff --git a/packages/core/src/utils/no-follow-open.test.ts b/packages/core/src/utils/no-follow-open.test.ts index 231130804e7..e8ede50dffd 100644 --- a/packages/core/src/utils/no-follow-open.test.ts +++ b/packages/core/src/utils/no-follow-open.test.ts @@ -12,7 +12,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import type { Stats } from 'node:fs'; +import type { BigIntStats, Stats } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -46,10 +46,10 @@ const itNoSymlink = process.platform === 'win32' ? it.skip : it; // Copy a Stats object with identity fields patched, keeping the prototype // so isSymbolicLink()/isFile() keep working on the perturbed result. -function perturbedStats( - stats: Stats, - patch: Partial>, -): Stats { +function perturbedStats( + stats: T, + patch: Partial>, +): T { return Object.assign( Object.create(Object.getPrototypeOf(stats)), stats, @@ -57,6 +57,12 @@ function perturbedStats( ); } +function differentIdentity(value: T): T { + return ( + typeof value === 'bigint' ? (value === 1n ? 2n : 1n) : value === 1 ? 2 : 1 + ) as T; +} + // Install a node:fs mock with O_NOFOLLOW removed so the module under test // takes the lstat/open/fstat fallback path. The `default` member is // LOAD-BEARING: no-follow-open.ts binds node:fs through a DEFAULT import, @@ -205,7 +211,7 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { mockNoFollowFs((actual) => ({ fstatSync: ((fd: number) => { const stats = actual.fstatSync(fd); - return perturbedStats(stats, { ino: stats.ino + 1 }); + return perturbedStats(stats, { ino: differentIdentity(stats.ino) }); }) as typeof actual.fstatSync, // Pin the rejection-path fd close: without it every sync fallback // refusal leaks the raw fd it opened for the identity re-check. @@ -236,7 +242,73 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { mockNoFollowFs((actual) => ({ fstatSync: ((fd: number) => { const stats = actual.fstatSync(fd); - return perturbedStats(stats, { dev: stats.dev + 1 }); + return perturbedStats(stats, { dev: differentIdentity(stats.dev) }); + }) as typeof actual.fstatSync, + })); + + const { openSyncNoFollow: openSyncFallback } = await import( + './no-follow-open.js' + ); + expect(() => openSyncFallback(filePath)).toThrow( + expect.objectContaining({ code: 'ELOOP' }), + ); + }); + + it('refuses an identity that differs only above 2^53 (NTFS file index)', async () => { + // NTFS reports a 64-bit file index and Node rounds it at the JS number + // boundary, so these two ids are distinct as bigints yet collapse to the + // SAME double. A number-backed comparison therefore waves the swap + // through, and `{ bigint: true }` at the stat call sites is the only thing + // that keeps the re-check exact on such a volume — this is the case that + // makes that conversion observable; every other test here passes with it + // removed, because Linux and macOS inodes are small. + // + // The offsets sit above 2^60, where the double spacing is 256: both round + // to 2^60. Offsets of 1 and 2 above 2^53 would NOT collapse — the spacing + // there is already 2, so 2^53+2 is exactly representable. + // + // The mock mirrors what Node really returns for each form of the call: a + // BigIntStats carrying the exact id, or a Stats carrying the rounded one. + // Asserting ELOOP (not EUNVERIFIABLE) pins the identity-mismatch branch: + // core's hasVerifiableInode is `Number(ino) !== 0`, so an id this large is + // still verifiable and still reaches the comparison. + const dir = makeTempDir(); + const filePath = join(dir, 'data.txt'); + writeFileSync(filePath, 'payload'); + + const PRE_OPEN_INO = 2n ** 60n + 1n; + const SWAPPED_INO = 2n ** 60n + 2n; + // Fixture guard: the whole case rests on these two collapsing to one + // double while staying distinct as bigints. Without this, editing the + // constants could silently degrade the test into a no-op. + expect(PRE_OPEN_INO).not.toBe(SWAPPED_INO); + expect(Number(PRE_OPEN_INO)).toBe(Number(SWAPPED_INO)); + + const wantsBigint = (opts: unknown): boolean => + typeof opts === 'object' && + opts !== null && + (opts as { bigint?: boolean }).bigint === true; + + mockNoFollowFs((actual) => ({ + lstatSync: ((...args: Parameters) => { + if (wantsBigint(args[1])) { + return perturbedStats(actual.lstatSync(args[0], { bigint: true }), { + ino: PRE_OPEN_INO, + }); + } + return perturbedStats(actual.lstatSync(args[0]), { + ino: Number(PRE_OPEN_INO), + }); + }) as typeof actual.lstatSync, + fstatSync: ((...args: Parameters) => { + if (wantsBigint(args[1])) { + return perturbedStats(actual.fstatSync(args[0], { bigint: true }), { + ino: SWAPPED_INO, + }); + } + return perturbedStats(actual.fstatSync(args[0]), { + ino: Number(SWAPPED_INO), + }); }) as typeof actual.fstatSync, })); @@ -268,7 +340,7 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { ...actual.promises, lstat: (async (p: string) => { const stats = await actual.promises.lstat(p); - return perturbedStats(stats, { ino: stats.ino + 1 }); + return perturbedStats(stats, { ino: differentIdentity(stats.ino) }); }) as typeof actual.promises.lstat, open: (async (...args: Parameters) => { const handle = await actual.promises.open(...args); @@ -293,20 +365,22 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { function mockNoFollowFsWithPerturbedSnapshot(): void { let lstatCalls = 0; mockNoFollowFs((actual) => { - const snapshotStats = (stats: Stats): Stats => { + const snapshotStats = (stats: T): T => { lstatCalls += 1; return lstatCalls === 1 ? stats - : perturbedStats(stats, { ino: stats.ino + 1 }); + : perturbedStats(stats, { ino: differentIdentity(stats.ino) }); }; return { - lstatSync: ((p: string) => - snapshotStats(actual.lstatSync(p))) as typeof actual.lstatSync, + lstatSync: ((...args: Parameters) => { + const stats = actual.lstatSync(...args); + return stats ? snapshotStats(stats) : stats; + }) as typeof actual.lstatSync, promises: { ...actual.promises, - lstat: (async (p: string) => + lstat: (async (...args: Parameters) => snapshotStats( - await actual.promises.lstat(p), + await actual.promises.lstat(...args), )) as typeof actual.promises.lstat, }, }; @@ -403,7 +477,7 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { mockNoFollowFs((actual) => ({ fstatSync: ((fd: number) => { const stats = actual.fstatSync(fd); - return perturbedStats(stats, { ino: stats.ino + 1 }); + return perturbedStats(stats, { ino: differentIdentity(stats.ino) }); }) as typeof actual.fstatSync, closeSync: (() => { throw Object.assign(new Error('close failed'), { code: 'EBADF' }); @@ -464,7 +538,7 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => { ...actual.promises, lstat: (async (p: string) => { const stats = await actual.promises.lstat(p); - return perturbedStats(stats, { ino: stats.ino + 1 }); + return perturbedStats(stats, { ino: differentIdentity(stats.ino) }); }) as typeof actual.promises.lstat, open: (async (...args: Parameters) => { const handle = await actual.promises.open(...args); diff --git a/packages/core/src/utils/no-follow-open.ts b/packages/core/src/utils/no-follow-open.ts index 2dcef8ec089..f141dee4187 100644 --- a/packages/core/src/utils/no-follow-open.ts +++ b/packages/core/src/utils/no-follow-open.ts @@ -96,8 +96,8 @@ function noFollowRejection( */ function assertSameIdentity( filePath: string, - before: fs.Stats, - after: fs.Stats, + before: { dev: number | bigint; ino: number | bigint }, + after: { dev: number | bigint; ino: number | bigint }, ): void { if (!hasVerifiableInode(before.ino)) { throw noFollowRejection( @@ -107,7 +107,10 @@ function assertSameIdentity( UNVERIFIABLE_IDENTITY_CODE, ); } - if (before.dev !== after.dev || before.ino !== after.ino) { + if ( + BigInt(before.dev) !== BigInt(after.dev) || + BigInt(before.ino) !== BigInt(after.ino) + ) { throw noFollowRejection( filePath, 'the file identity changed between the pre-open check and the open ' + @@ -137,13 +140,13 @@ export function openSyncNoFollow(filePath: string): number { return fs.openSync(filePath, baseFlags | noFollowFlag); } - const before = fs.lstatSync(filePath); + const before = fs.lstatSync(filePath, { bigint: true }); if (before.isSymbolicLink()) { throw noFollowRejection(filePath, 'the path is a symlink'); } const fd = fs.openSync(filePath, baseFlags); try { - assertSameIdentity(filePath, before, fs.fstatSync(fd)); + assertSameIdentity(filePath, before, fs.fstatSync(fd, { bigint: true })); } catch (error) { try { fs.closeSync(fd); @@ -180,13 +183,13 @@ export async function openNoFollow(filePath: string): Promise { return fs.promises.open(filePath, baseFlags | noFollowFlag); } - const before = await fs.promises.lstat(filePath); + const before = await fs.promises.lstat(filePath, { bigint: true }); if (before.isSymbolicLink()) { throw noFollowRejection(filePath, 'the path is a symlink'); } const handle = await fs.promises.open(filePath, baseFlags); try { - assertSameIdentity(filePath, before, await handle.stat()); + assertSameIdentity(filePath, before, await handle.stat({ bigint: true })); } catch (error) { await handle.close().catch(() => { // The rejection below is the primary error; closing is best-effort. diff --git a/packages/core/src/utils/sessionStorageUtils.test.ts b/packages/core/src/utils/sessionStorageUtils.test.ts index 8537f889519..886cc663362 100644 --- a/packages/core/src/utils/sessionStorageUtils.test.ts +++ b/packages/core/src/utils/sessionStorageUtils.test.ts @@ -428,6 +428,7 @@ describe('sessionStorageUtils', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -556,8 +557,10 @@ describe('sessionStorageUtils', () => { const oNofollow: number | undefined = fs.constants?.O_NOFOLLOW; const identityFstats = oNofollow === undefined ? 1 : 0; let fstatCalls = 0; - vi.spyOn(fs, 'fstatSync').mockImplementation(((fd: number) => { - const stats = originalFstatSync(fd); + vi.spyOn(fs, 'fstatSync').mockImplementation((( + ...args: Parameters + ) => { + const stats = originalFstatSync(...args); if (fstatCalls++ === identityFstats) stats.size = initialSize; return stats; }) as typeof fs.fstatSync); @@ -581,8 +584,10 @@ describe('sessionStorageUtils', () => { const oNofollow: number | undefined = fs.constants?.O_NOFOLLOW; const identityFstats = oNofollow === undefined ? 1 : 0; let fstatCalls = 0; - vi.spyOn(fs, 'fstatSync').mockImplementation(((fd: number) => { - const stats = originalFstatSync(fd); + vi.spyOn(fs, 'fstatSync').mockImplementation((( + ...args: Parameters + ) => { + const stats = originalFstatSync(...args); if (fstatCalls++ === identityFstats) stats.size = initialSize; return stats; }) as typeof fs.fstatSync); @@ -621,6 +626,7 @@ describe('sessionStorageUtils', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -1029,6 +1035,7 @@ describe('sessionStorageUtils', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/qwen-live/package.json b/packages/qwen-live/package.json index 14fa39ca891..7bb27c39a9e 100644 --- a/packages/qwen-live/package.json +++ b/packages/qwen-live/package.json @@ -48,6 +48,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@node-rs/jieba": "2.0.2", + "@node-rs/jieba-wasm32-wasi": "2.0.2", "@qwen-code/sdk": "file:../sdk-typescript", "ansi-regex": "^6.2.2", "prompts": "^2.4.2", diff --git a/packages/qwen-live/src/language-preferences.test.ts b/packages/qwen-live/src/language-preferences.test.ts index a15b96c6d66..5609af9a524 100644 --- a/packages/qwen-live/src/language-preferences.test.ts +++ b/packages/qwen-live/src/language-preferences.test.ts @@ -64,7 +64,8 @@ describe('Live language preference', () => { ...raw, language: 'zh-CN', }); - expect(statSync(path).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') + expect(statSync(path).mode & 0o777).toBe(0o600); expect(readdirSync(dataDir)).toEqual(['config.json']); }); diff --git a/packages/qwen-live/src/memory/config.test.ts b/packages/qwen-live/src/memory/config.test.ts index 542cc9a2aeb..34d6a3ef346 100644 --- a/packages/qwen-live/src/memory/config.test.ts +++ b/packages/qwen-live/src/memory/config.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -15,7 +15,7 @@ import { validateMemoryBaseUrl, } from './config.js'; -const dataDir = '/tmp/qwen-memory-config-tests'; +const dataDir = join(tmpdir(), 'qwen-memory-config-tests'); const resolve = (raw?: unknown) => resolveMemoryConfig(raw, dataDir, join(dataDir, 'config.json')); @@ -51,13 +51,12 @@ describe('memory configuration', () => { }); it('resolves relative and tilde paths without requiring the directory to exist', () => { + const separateDir = join(tmpdir(), 'separate-memories'); expect(resolve({ dir: 'saved' }).dir).toBe(join(dataDir, 'saved')); expect(resolve({ dir: '~/qwen-memory-config-tests' }).dir).toBe( join(homedir(), 'qwen-memory-config-tests'), ); - expect(resolve({ dir: '/tmp/separate-memories' }).dir).toBe( - '/tmp/separate-memories', - ); + expect(resolve({ dir: separateDir }).dir).toBe(separateDir); }); it.each(['retrieve', 'preload', 'updater', 'observer', 'wm', 'segment'])( diff --git a/packages/qwen-live/src/memory/service.test.ts b/packages/qwen-live/src/memory/service.test.ts index 88baf6b7691..039ffa215bf 100644 --- a/packages/qwen-live/src/memory/service.test.ts +++ b/packages/qwen-live/src/memory/service.test.ts @@ -129,7 +129,9 @@ describe('persistMemoryPreferences', () => { retrieve: { useVector: false }, }); expect(resolved.updater.model).toBe('custom-memory-model'); - expect(statSync(configPath).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o600); + } expect(readdirSync(dataDir)).toEqual(['config.json']); }); @@ -175,7 +177,9 @@ describe('persistMemoryPreferences', () => { }); expect(saved.memory.observer).not.toHaveProperty('model'); expect(resolved.observer.model).toBe('custom-memory-model'); - expect(statSync(configPath).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o600); + } expect(readdirSync(dataDir)).toEqual(['config.json']); }); diff --git a/packages/qwen-live/src/proactive/monitor-debug-store.test.ts b/packages/qwen-live/src/proactive/monitor-debug-store.test.ts index b968c3be532..32b5e5a0e1b 100644 --- a/packages/qwen-live/src/proactive/monitor-debug-store.test.ts +++ b/packages/qwen-live/src/proactive/monitor-debug-store.test.ts @@ -225,25 +225,27 @@ describe('MonitorDebugStore', () => { text: 'Reply [redacted]', result: 'reply', }); - for (const path of [ - root, - archive.directory, - join(archive.directory, 'requests'), - directory, - ]) { - expect((await lstat(path)).mode & 0o777).toBe(0o700); - } - for (const path of [ - join(archive.directory, 'monitor.json'), - ...[ - 'request.json', - 'response.json', - 'image-0001.jpg', - 'image-0002.jpg', - 'input.wav', - ].map((file) => join(directory, file)), - ]) { - expect((await lstat(path)).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') { + for (const path of [ + root, + archive.directory, + join(archive.directory, 'requests'), + directory, + ]) { + expect((await lstat(path)).mode & 0o777).toBe(0o700); + } + for (const path of [ + join(archive.directory, 'monitor.json'), + ...[ + 'request.json', + 'response.json', + 'image-0001.jpg', + 'image-0002.jpg', + 'input.wav', + ].map((file) => join(directory, file)), + ]) { + expect((await lstat(path)).mode & 0o777).toBe(0o600); + } } expect(log).toHaveBeenCalledWith( 'proactive.monitor_request_saved', @@ -406,10 +408,12 @@ describe('MonitorDebugStore', () => { it('rejects shared or symlink archive roots without touching their contents', async () => { await mkdir(root, { mode: 0o700 }); - await chmod(root, 0o755); await writeFile(join(root, 'keep.txt'), 'keep'); - expect(await store.initialize()).toBe(false); - expect(store.create(INFO)).toBeUndefined(); + if (process.platform !== 'win32') { + await chmod(root, 0o755); + expect(await store.initialize()).toBe(false); + expect(store.create(INFO)).toBeUndefined(); + } const linked = new MonitorDebugStore( log, join(temporary, 'linked-archives'), diff --git a/packages/qwen-live/src/proactive/monitor-debug-store.ts b/packages/qwen-live/src/proactive/monitor-debug-store.ts index d5f05b4891e..bf059f415af 100644 --- a/packages/qwen-live/src/proactive/monitor-debug-store.ts +++ b/packages/qwen-live/src/proactive/monitor-debug-store.ts @@ -42,8 +42,10 @@ async function privateDirectory(path: string): Promise { if ( !stat.isDirectory() || stat.isSymbolicLink() || - (stat.mode & 0o077) !== 0 || - (process.getuid && stat.uid !== process.getuid()) + (process.platform !== 'win32' && + ((stat.mode & 0o077) !== 0 || + (typeof process.getuid === 'function' && + stat.uid !== process.getuid()))) ) throw new Error('unsafe_directory'); } diff --git a/packages/vscode-ide-companion/src/diff-manager.test.ts b/packages/vscode-ide-companion/src/diff-manager.test.ts index b0cf86e6c17..12f175a6451 100644 --- a/packages/vscode-ide-companion/src/diff-manager.test.ts +++ b/packages/vscode-ide-companion/src/diff-manager.test.ts @@ -6,8 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; +import path from 'node:path'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; +const workspaceRoot = path.resolve('/test/workspace1'); +const workspaceFile = path.join(workspaceRoot, 'src/foo.ts'); + const { workspaceMock, openTextDocument, executeCommand, tabGroups } = vi.hoisted(() => ({ workspaceMock: { @@ -52,7 +56,7 @@ vi.mock('vscode', () => ({ Uri: { file: (fsPath: string) => makeUri(fsPath), joinPath: (base: { fsPath: string }, filePath: string) => - makeUri(`${base.fsPath}/${filePath}`), + makeUri(path.join(base.fsPath, filePath)), }, EventEmitter: class { private listeners: Array<(e: unknown) => void> = []; @@ -82,7 +86,7 @@ describe('DiffManager path resolution', () => { beforeEach(() => { vi.clearAllMocks(); - workspaceMock.workspaceFolders = [{ uri: { fsPath: '/test/workspace1' } }]; + workspaceMock.workspaceFolders = [{ uri: { fsPath: workspaceRoot } }]; tabGroups.all = []; // The right-hand pane is read back through openTextDocument when a diff is // closed; the text it returns is what closeDiff resolves with. @@ -104,13 +108,13 @@ describe('DiffManager path resolution', () => { it('closes a relative-opened diff when asked with the absolute path', async () => { await diffManager.showDiff('src/foo.ts', 'old', 'new'); - await expect( - diffManager.closeDiff('/test/workspace1/src/foo.ts'), - ).resolves.toBe('new content'); + await expect(diffManager.closeDiff(workspaceFile)).resolves.toBe( + 'new content', + ); }); it('closes an absolute-opened diff when asked with the relative path', async () => { - await diffManager.showDiff('/test/workspace1/src/foo.ts', 'old', 'new'); + await diffManager.showDiff(workspaceFile, 'old', 'new'); await expect(diffManager.closeDiff('src/foo.ts')).resolves.toBe( 'new content', @@ -122,7 +126,7 @@ describe('DiffManager path resolution', () => { // dedupes on identical old/new content: a webview permission-preview // diff opened with the absolute form, and a second session's differently // proposed edit opened with the relative form. - await diffManager.showDiff('/test/workspace1/src/foo.ts', 'o1', 'n1'); + await diffManager.showDiff(workspaceFile, 'o1', 'n1'); const firstRightUri = executeCommand.mock.calls.find( (call) => call[0] === 'vscode.diff', )?.[2]; @@ -145,7 +149,7 @@ describe('DiffManager path resolution', () => { it('echoes the path the diff was opened with, not the one used to close', async () => { await diffManager.showDiff('src/foo.ts', 'old', 'new'); - await diffManager.closeDiff('/test/workspace1/src/foo.ts'); + await diffManager.closeDiff(workspaceFile); expect(notifications).toHaveLength(1); expect(notifications[0].params).toMatchObject({ @@ -174,15 +178,15 @@ describe('DiffManager path resolution', () => { const diffCall = executeCommand.mock.calls.find( (call) => call[0] === 'vscode.diff', ); - expect(diffCall?.[1].fsPath).toBe('/test/workspace1/src/foo.ts'); - expect(diffCall?.[2].fsPath).toBe('/test/workspace1/src/foo.ts'); + expect(diffCall?.[1].fsPath).toBe(workspaceFile); + expect(diffCall?.[2].fsPath).toBe(workspaceFile); }); it('reads the old content from the resolved path', async () => { await diffManager.showDiff('src/foo.ts', 'new'); expect(openTextDocument).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: '/test/workspace1/src/foo.ts' }), + expect.objectContaining({ fsPath: workspaceFile }), ); }); @@ -197,7 +201,7 @@ describe('DiffManager path resolution', () => { const diffCall = executeCommand.mock.calls.find( (call) => call[0] === 'vscode.diff', ); - expect(diffCall?.[1].fsPath).toBe('src/foo.ts'); + expect(diffCall?.[1].fsPath).toBe(path.normalize('src/foo.ts')); }); it('returns undefined when no diff matches the requested path', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c61b0fa9c97..91f32c9c984 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: '@types/react': ^19.2.0 '@types/react-dom': ^19.2.0 axios: ^1.19.0 + '@tybys/wasm-util': 0.10.3 react-markdown: ^9.0.0 '@agentclientprotocol/sdk>zod': 3.25.76 @@ -36,6 +37,12 @@ importers: specifier: ^3.36.0 version: 3.36.0(supports-color@7.2.0) devDependencies: + '@emnapi/core': + specifier: 1.11.3 + version: 1.11.3 + '@emnapi/runtime': + specifier: 1.11.3 + version: 1.11.3 '@types/chrome': specifier: ^0.1.32 version: 0.1.43 @@ -1137,6 +1144,9 @@ importers: '@node-rs/jieba': specifier: 2.0.2 version: 2.0.2 + '@node-rs/jieba-wasm32-wasi': + specifier: 2.0.2 + version: 2.0.2 '@qwen-code/sdk': specifier: workspace:* version: link:../sdk-typescript @@ -1826,9 +1836,24 @@ packages: '@dotenvx/primitives@0.8.0': resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/core@2.0.0-alpha.4': + resolution: {integrity: sha512-KjoUR6mNvjT+z5bKMy+a5QDI+kTNTiMZi6uPKzWuWjwZGNa0UPwIVduTfeBRdf6v8Ws5fmHDh7Usf15w/ANfkg==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@2.0.0-alpha.4': + resolution: {integrity: sha512-Wy1TQ99obRP3Ah7cf/OOtK1zD9t3pYTWz+G/SpAwTFMhG5hoMdlvfzBpYoHUigS/izu3q+VhYMKhJd7Ii/trpg==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2834,6 +2859,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -2905,6 +2937,10 @@ packages: os: [linux] libc: [musl] + '@node-rs/jieba-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-dCwPgGljuxCCqNK7VmOWGTgs/aFX+A9oeCerm0KY3GWAhZMwQwe5gjB/j05ypY37TB6Og3JtJQ/ptBIYe0NT+Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + '@node-rs/jieba-win32-arm64-msvc@2.0.2': resolution: {integrity: sha512-EI3JLL01kf6pP3mAUoYnDrt8S+FY2W2nWpjtiK7ucs46tITLkYDx9wmAwDsOnoCkxs4CETzLytPhLZ12lzR5Qw==} engines: {node: '>= 10'} @@ -4422,6 +4458,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/archiver@6.0.3': resolution: {integrity: sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==} @@ -10703,10 +10742,31 @@ snapshots: '@dotenvx/primitives@0.8.0': {} + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + + '@emnapi/core@2.0.0-alpha.4': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 - optional: true + + '@emnapi/runtime@2.0.0-alpha.4': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 '@esbuild/aix-ppc64@0.21.5': optional: true @@ -11559,6 +11619,12 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@2.0.0-alpha.4)(@emnapi/runtime@2.0.0-alpha.4)': + dependencies: + '@emnapi/core': 2.0.0-alpha.4 + '@emnapi/runtime': 2.0.0-alpha.4 + '@tybys/wasm-util': 0.10.3 + '@noble/hashes@1.8.0': {} '@nodable/entities@2.2.0': {} @@ -11593,6 +11659,12 @@ snapshots: '@node-rs/jieba-linux-x64-musl@2.0.2': optional: true + '@node-rs/jieba-wasm32-wasi@2.0.2': + dependencies: + '@emnapi/core': 2.0.0-alpha.4 + '@emnapi/runtime': 2.0.0-alpha.4 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@2.0.0-alpha.4)(@emnapi/runtime@2.0.0-alpha.4) + '@node-rs/jieba-win32-arm64-msvc@2.0.2': optional: true @@ -13186,6 +13258,10 @@ snapshots: minimatch: 10.2.5 path-browserify: 1.0.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + '@types/archiver@6.0.3': dependencies: '@types/readdir-glob': 1.1.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5082c32f27a..58323e53b97 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,13 @@ overrides: '@types/react': '^19.2.0' '@types/react-dom': '^19.2.0' axios: '^1.19.0' + # @node-rs/jieba-wasm32-wasi brings in @napi-rs/wasm-runtime, which accepts + # ^0.10.3. Unpinned it resolves to 0.10.4, published 2026-09-13, which the + # minimumReleaseAge gate rejects until it ages out. The gate is enforced by + # pnpm itself, and scripts/tests/package-scripts.test.js keeps + # minimumReleaseAgeExclude internal-only, so the age-compliant version is + # pinned here rather than exempted there. + '@tybys/wasm-util': '0.10.3' react-markdown: '^9.0.0' # packages/acp-bridge declares no zod, so pnpm is free to satisfy the SDK's # `^3.25.0 || ^4.0.0` peer with the highest locked zod while npm serves it diff --git a/scripts/tests/check-tui-dep-direction.test.js b/scripts/tests/check-tui-dep-direction.test.js index a6d5804a9e1..50bc812b310 100644 --- a/scripts/tests/check-tui-dep-direction.test.js +++ b/scripts/tests/check-tui-dep-direction.test.js @@ -18,7 +18,7 @@ import { writeFileSync, } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { bannedFamily, @@ -581,7 +581,7 @@ describe('end-to-end gate run (main wiring)', () => { [ '--input-type=module', '-e', - `await import('file://${join(base, 'scripts', 'check-tui-dep-direction.mjs')}'); process.stdout.write('imported-only');`, + `await import(${JSON.stringify(pathToFileURL(join(base, 'scripts', 'check-tui-dep-direction.mjs')).href)}); process.stdout.write('imported-only');`, ], { encoding: 'utf8', timeout: 30000 }, ); diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 0979e2b0085..ac8e562595e 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -980,6 +980,6 @@ describe('Windows temp short-alias guard', () => { // configure-windows-runner and the hosted redirect both set TEMP and TMP, // so an unset value means one of them stopped running — a clear message // beats realpathSync(undefined)'s TypeError. - expect(() => runGuard({})).toThrow(/TEMP is not set/); + expect(() => runGuard({ TEMP: '', TMP: '' })).toThrow(/TEMP is not set/); }); });