Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c067b92
fix(core): keep no-follow reads protected where O_NOFOLLOW is missing
yiliang114 Aug 25, 2026
bdecd4c
fix(core): keep the no-follow helper mockable by fs spy suites
yiliang114 Aug 25, 2026
84df828
fix(core): distinguish inode-unverifiable refusals from ELOOP
yiliang114 Aug 25, 2026
55ce9d7
refactor(core): drop the unused flags/mode params from the no-follow …
yiliang114 Aug 25, 2026
47d0757
test(core): pin the symlink refusal for the plural session-field read
yiliang114 Aug 25, 2026
88d16cb
test(core): pin the async identity re-check of the no-follow fallback
yiliang114 Aug 25, 2026
7354a1f
fix(acp): keep no-follow helper off the core barrel
yiliang114 Aug 25, 2026
294ffd8
fix(cli): keep registration read off core barrel
yiliang114 Aug 26, 2026
d6e4a63
test: resolve no-follow core subpath in consumers
yiliang114 Aug 26, 2026
c60046d
test(core): exercise output-tail no-follow fallback
yiliang114 Aug 26, 2026
b0d5da7
test(cli): pin unverifiable registration identity
yiliang114 Aug 26, 2026
b89e739
Merge branch 'main' into fix/issue-8227-windows-nofollow
wenshao Aug 26, 2026
842bd9f
Merge remote-tracking branch 'fork114/fix/issue-8227-windows-nofollow'
yiliang114 Aug 26, 2026
3af3575
build: map noFollowOpen subpath in typecheck programs
yiliang114 Aug 26, 2026
e2b5965
test(core): pin fd close, dev check, and pre-open snapshot
yiliang114 Aug 26, 2026
a74e249
test(core): pin inode-0 degradation branches in sessionArtifacts and …
yiliang114 Aug 26, 2026
4fae763
test(core): run sync fallback identity re-checks on all platforms
yiliang114 Aug 26, 2026
3030173
build(cli): map noFollowOpen subpath in cli typecheck paths
yiliang114 Aug 26, 2026
5aec7c2
test(core): dedupe no-follow-open fallback mocks, pin best-effort close
yiliang114 Aug 26, 2026
e2c80ed
Merge branch 'main' into fix/issue-8227-windows-nofollow
Aug 27, 2026
07c31e8
Merge branch 'main' into fix/issue-8227-windows-nofollow
yiliang114 Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions integration-tests/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"@qwen-code/qwen-code-core/transcriptRecords": [
"../packages/core/src/utils/transcript-records.ts"
],
"@qwen-code/qwen-code-core/noFollowOpen": [
"../packages/core/src/utils/no-follow-open.ts"
],
"@qwen-code/qwen-code-core/goalWire": [
"../packages/core/src/goals/goal-wire.ts"
],
Expand Down
88 changes: 88 additions & 0 deletions packages/acp-bridge/src/sessionArtifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type SessionArtifactEventRecordPayload,
type SessionArtifactSnapshotRecordPayload,
} from '@qwen-code/qwen-code-core';
import { UNVERIFIABLE_IDENTITY_CODE } from '@qwen-code/qwen-code-core/noFollowOpen';

vi.mock('@xterm/headless', () => ({
Terminal: class Terminal {},
Expand Down Expand Up @@ -4087,6 +4088,93 @@ describe('SessionArtifactStore', () => {
}
});

it('treats an unverifiable inode identity as missing, not an escape, on upsert', async () => {
// On volumes that never report inode numbers (ino 0: FAT/exFAT, some
// SMB shares) openNoFollow cannot prove the opened file matches the
// pre-open check and refuses with UNVERIFIABLE_IDENTITY_CODE. The
// upsert must then degrade to a plain missing artifact — it must not
// reject outright, and it must not raise the symlink-escape flag for a
// path the containment check already accepted (#8227 follow-up).
const store = new SessionArtifactStore({
sessionId: 's7-unverifiable-upsert',
workspaceCwd: workspace,
});
await fs.writeFile(path.join(workspace, 'report.txt'), 'hello');
const originalOpen = fs.open.bind(fs);
const openSpy = vi
.spyOn(fs, 'open')
.mockImplementation(async (entry, flags, mode) => {
if (String(entry).endsWith('report.txt')) {
throw Object.assign(new Error('inode 0 cannot be verified'), {
code: UNVERIFIABLE_IDENTITY_CODE,
});
}
return originalOpen(entry, flags, mode);
});

try {
const created = await store.upsertMany(
[{ title: 'Report', workspacePath: 'report.txt' }],
{ strict: true },
);
expect(created.changes).toHaveLength(1);
expect(created.changes[0]).toMatchObject({
action: 'created',
artifact: expect.objectContaining({
status: 'missing',
workspacePath: 'report.txt',
}),
});
} finally {
openSpy.mockRestore();
}
});

it('keeps reporting unverifiable artifacts missing on refresh without an escape flag', async () => {
const store = new SessionArtifactStore({
sessionId: 's7-unverifiable-refresh',
workspaceCwd: workspace,
});
await fs.writeFile(path.join(workspace, 'report.txt'), 'hello');
await store.upsertMany([{ title: 'Report', workspacePath: 'report.txt' }], {
strict: true,
});

vi.useFakeTimers();
vi.setSystemTime(new Date(Date.now() + 6_000));
const originalOpen = fs.open.bind(fs);
const openSpy = vi
.spyOn(fs, 'open')
.mockImplementation(async (entry, flags, mode) => {
if (String(entry).endsWith('report.txt')) {
throw Object.assign(new Error('inode 0 cannot be verified'), {
code: UNVERIFIABLE_IDENTITY_CODE,
});
}
return originalOpen(entry, flags, mode);
});
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);

try {
const artifact = (await store.list()).artifacts[0];
expect(artifact).toMatchObject({
status: 'missing',
workspacePath: 'report.txt',
});
expect(artifact).not.toHaveProperty('sizeBytes');
// The degradation is a graceful missing status, not a refresh error:
// deleting the branch would re-throw the refusal and log this marker.
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).not.toContain('status_refresh_failed');
} finally {
vi.useRealTimers();
openSpy.mockRestore();
stderr.mockRestore();
}
});

it('rejects relative dangling symlinks that point outside the workspace', async () => {
const store = new SessionArtifactStore({
sessionId: 's7-dangling-symlink',
Expand Down
26 changes: 16 additions & 10 deletions packages/acp-bridge/src/sessionArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@
*/

import { createHash } from 'node:crypto';
import {
constants as fsConstants,
promises as fs,
type BigIntStats,
type Stats,
} from 'node:fs';
import { promises as fs, type BigIntStats, type Stats } from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import {
Expand Down Expand Up @@ -40,6 +35,10 @@ import type {
SessionArtifactRetention,
SessionArtifactSnapshotRecordPayload,
} from '@qwen-code/qwen-code-core';
import {
isUnverifiableIdentityError,
openNoFollow,
} from '@qwen-code/qwen-code-core/noFollowOpen';
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
import { writeStderrLine } from './internal/stderrLine.js';

export type DaemonSessionArtifactKind =
Expand Down Expand Up @@ -3302,10 +3301,10 @@ async function getWorkspaceStatus(
// Number spelling loses precision above 2^53, so two files created close
// together can round to the SAME numeric ino and defeat the swap check.
const preOpenStat = await fs.lstat(realPath, { bigint: true });
const handle = await fs.open(
realPath,
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
);
// Where O_NOFOLLOW does not exist (Windows) the helper compensates
// with an lstat/open/fstat identity check instead of collapsing to a
// plain open that follows symlinks (#8227).
const handle = await openNoFollow(realPath);
try {
if (!isSameFile(preOpenStat, await handle.stat({ bigint: true }))) {
return { status: 'missing', escaped: true };
Expand Down Expand Up @@ -3374,6 +3373,13 @@ async function getWorkspaceStatus(
if (isNoFollowSymlinkError(error)) {
return { status: 'missing', escaped: true };
}
if (isUnverifiableIdentityError(error)) {
Comment thread
yiliang114 marked this conversation as resolved.
// inode-0 volume: the file could not be proven identical to the one
// the pre-open check saw. Fail closed like a missing artifact, but
// do NOT flag a symlink escape we did not observe — the path passed
// the containment check above (#8227 follow-up).
return { status: 'missing' };
}
if (!isNotFoundError(error)) {
throw error;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"@qwen-code/qwen-code-core/transcriptRecords": [
"../core/src/utils/transcript-records.ts"
],
"@qwen-code/qwen-code-core/noFollowOpen": [
"../core/src/utils/no-follow-open.ts"
],
"@qwen-code/qwen-code-core/*": ["../core/src/*"]
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/acp-bridge/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@qwen-code/qwen-code-core/noFollowOpen': path.resolve(
__dirname,
'../core/src/utils/no-follow-open.ts',
),
'@qwen-code/qwen-code-core/subSessionConstants': path.resolve(
__dirname,
'../core/src/tools/sub-session-constants.ts',
Expand Down
64 changes: 61 additions & 3 deletions packages/cli/src/serve/workspace-registration-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,57 @@ describe('WorkspaceRegistrationStore', () => {
await expect(store.read()).rejects.toThrow(/regular file/);
});

it('reports an unverifiable store identity as a store error', async () => {
const home = await tempHome();
vi.resetModules();
vi.doMock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const modifiedPromises = {
...actual.promises,
lstat: vi.fn(
async (...args: Parameters<typeof actual.promises.lstat>) => {
const stats = await actual.promises.lstat(...args);
return new Proxy(stats, {
get: (target, property, receiver) =>
property === 'ino'
? 0
: Reflect.get(target, property, receiver),
});
},
),
};
const modified = {
...actual,
constants: { ...actual.constants, O_NOFOLLOW: undefined },
promises: modifiedPromises,
};
return { ...modified, default: modified };
});
try {
const storeModule = await import('./workspace-registration-store.js');
const store = new storeModule.WorkspaceRegistrationStore(
'/work/primary',
home,
);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
await fs.writeFile(
store.filePath,
JSON.stringify({
schemaVersion: 1,
primaryWorkspace: '/work/primary',
workspaces: [],
}),
);

await expect(store.read()).rejects.toThrow(
/identity could not be verified/,
);
} finally {
vi.doUnmock('node:fs');
vi.resetModules();
}
});

it('rejects an oversized store', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
Expand Down Expand Up @@ -472,9 +523,16 @@ describe('WorkspaceRegistrationStore', () => {
}),
},
}));
vi.doMock('@qwen-code/qwen-code-core', () => ({
atomicWriteFile: vi.fn().mockRejectedValue(writeError),
}));
// The read path uses the leaf noFollowOpen export, so this barrel mock
// remains limited to the deferred write helper under test.
vi.doMock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
atomicWriteFile: vi.fn().mockRejectedValue(writeError),
};
});
try {
const storeModule = await import('./workspace-registration-store.js');
const store = new storeModule.WorkspaceRegistrationStore(
Expand Down
22 changes: 17 additions & 5 deletions packages/cli/src/serve/workspace-registration-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
*/

import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import lockfile from 'proper-lockfile';
import {
isUnverifiableIdentityError,
openNoFollow,
} from '@qwen-code/qwen-code-core/noFollowOpen';
Comment thread
yiliang114 marked this conversation as resolved.
import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths';
import { getGlobalQwenDirLite } from '../config/storage-paths-lite.js';
import { MAX_REGISTERED_WORKSPACES } from './workspace-inputs.js';
Expand Down Expand Up @@ -355,14 +358,23 @@ export class WorkspaceRegistrationStore {
}
let file: Awaited<ReturnType<typeof fs.open>>;
try {
file = await fs.open(
this.filePath,
(constants.O_RDONLY ?? 0) | (constants.O_NOFOLLOW ?? 0),
);
// Where O_NOFOLLOW does not exist (Windows) the helper compensates
// with an lstat/open/fstat identity check instead of collapsing to a
// plain open that follows symlinks (#8227).
file = await openNoFollow(this.filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return emptySnapshot(this.primaryWorkspace);
}
if (isUnverifiableIdentityError(err)) {
Comment thread
yiliang114 marked this conversation as resolved.
// inode-0 volume: the store could not be proven identical to the
// file the pre-open check saw. Fail closed, but do not claim it
// "must be a regular file" — the lstat gate above already proved
// it is one (#8227 follow-up).
throw new WorkspaceRegistrationStoreError(
'Workspace registration store identity could not be verified',
);
}
if ((err as NodeJS.ErrnoException).code === 'ELOOP') {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store must be a regular file',
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"@qwen-code/qwen-code-core/transcriptRecords": [
"../core/src/utils/transcript-records.ts"
],
"@qwen-code/qwen-code-core/noFollowOpen": [
"../core/src/utils/no-follow-open.ts"
],
"@qwen-code/qwen-code-core/*": ["../core/src/*"],
"@qwen-code/acp-bridge": ["../acp-bridge/src/index.ts"],
"@qwen-code/acp-bridge/transcriptReplay": [
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@qwen-code/qwen-code-core/noFollowOpen': path.resolve(
__dirname,
'../core/src/utils/no-follow-open.ts',
),
'@qwen-code/qwen-code-core/subSessionConstants': path.resolve(
__dirname,
'../core/src/tools/sub-session-constants.ts',
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./dist/src/hooks/user-prompt-submit-context.d.ts",
"import": "./dist/src/hooks/user-prompt-submit-context.js"
},
"./noFollowOpen": {
Comment thread
yiliang114 marked this conversation as resolved.
"types": "./dist/src/utils/no-follow-open.d.ts",
"import": "./dist/src/utils/no-follow-open.js"
},
"./package.json": "./package.json",
"./dist/*": "./dist/*",
"./src/*": "./src/*"
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export {
export { atomicWriteFile } from './utils/atomicFileWrite.js';
export { nextFireTime, parseCron } from './utils/cronParser.js';
export { isWsl } from './utils/terminal-env.js';
export {
isUnverifiableIdentityError,
openNoFollow,
openSyncNoFollow,
UNVERIFIABLE_IDENTITY_CODE,
} from './utils/no-follow-open.js';
export * from './services/session-organization-service.js';

// Backward-compatible type re-exports for tool classes removed from eager loading.
Expand Down
Loading
Loading