Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix(core): distinguish inode-unverifiable refusals from ELOOP
The fallback's inode-0 fail-closed refusal carried code 'ELOOP' like a
genuine symlink refusal, so consumers with ELOOP-specific handling
misfired on LEGITIMATE files on inode-0 volumes (Windows FAT/exFAT/SMB,
where Node reports ino 0): session-artifact workspace status flagged a
contained file as an escape, the workspace registration store reported a
regular store as "must be a regular file", and untracked text files
rendered as binary with dropped hunks. Give the inode-unverifiable
refusal its own code (EUNVERIFIABLE) plus an isUnverifiableIdentityError
guard, keep ELOOP for genuine symlink refusals and identity races, and
adjust the three consumers: the artifact status degrades to plain
'missing', the store surfaces an identity-unverifiable error, and the
untracked diff read falls back to the pre-#8227 plain read (its lstat
gate already rejected symlinks and non-regular files).

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
  • Loading branch information
yiliang114 and Qwen-Coder committed Aug 25, 2026
commit 84df828c035a490bf04aa8ac3c27ef7c9ced4353
8 changes: 8 additions & 0 deletions packages/acp-bridge/src/sessionArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isPrototypeMetadataKey,
isRecordableDerivedChild,
isReservedWorkspaceMetadataKey,
isUnverifiableIdentityError,
MAX_DIRECTORY_ARTIFACT_DEPTH,
MAX_DIRECTORY_ARTIFACT_FILES,
metadataBudgetBytes,
Expand Down Expand Up @@ -3178,6 +3179,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
14 changes: 13 additions & 1 deletion packages/cli/src/serve/workspace-registration-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ 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 { openNoFollow } from '@qwen-code/qwen-code-core';
import {
isUnverifiableIdentityError,
openNoFollow,
} from '@qwen-code/qwen-code-core';
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 @@ -363,6 +366,15 @@ export class WorkspaceRegistrationStore {
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
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ export {
export { atomicWriteFile } from './utils/atomicFileWrite.js';
export { nextFireTime, parseCron } from './utils/cronParser.js';
export { isWsl } from './utils/terminal-env.js';
export { openNoFollow, openSyncNoFollow } from './utils/no-follow-open.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
58 changes: 40 additions & 18 deletions packages/core/src/utils/gitDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
*/

import { execFile } from 'node:child_process';
import { access, lstat, readFile, stat } from 'node:fs/promises';
import { access, lstat, open, readFile, stat } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import * as path from 'node:path';
import { promisify } from 'node:util';
import type { Hunk } from 'diff';
import { findGitRoot, readFirstLineNoFollow } from './gitUtils.js';
import { openNoFollow } from './no-follow-open.js';
import { isUnverifiableIdentityError, openNoFollow } from './no-follow-open.js';

/** Re-export so consumers don't need to depend on `diff` directly. */
export type GitDiffHunk = Hunk;
Expand Down Expand Up @@ -84,6 +85,33 @@ const UNTRACKED_READ_CHUNK_BYTES = 64 * 1024;
/** Scan the first N bytes for NUL to detect binary files (matches git's heuristic). */
const BINARY_SNIFF_BYTES = 8 * 1024;

/**
* Open an untracked file for diff display through {@link openNoFollow},
* degrading to a plain read only where the helper's fail-closed refusal is
* the inode-unverifiable one (ino 0: FAT/exFAT, some SMB shares on Windows).
* Every call site gates on `lstat(...).isFile()` immediately before the
* open, so the fallback cannot follow a symlink the gate did not already
* accept — it merely restores the pre-#8227 read for volumes where identity
* can never be proven. Without the degradation, EVERY untracked text file on
* such a volume would collapse to a binary row / dropped hunk even though
* diff display is not identity-sensitive. Any other refusal (a genuine
* symlink race) and any plain-open error return `undefined`.
*/
async function openUntrackedForDiffRead(
Comment thread
yiliang114 marked this conversation as resolved.
absPath: string,
): Promise<FileHandle | undefined> {
try {
return await openNoFollow(absPath);
} catch (error) {
if (!isUnverifiableIdentityError(error)) return undefined;
}
try {
return await open(absPath);
} catch {
return undefined;
}
}

/**
* Fetch numstat-based git diff stats (files changed, lines added/removed) and
* per-file summaries comparing the working tree to HEAD. Structured hunks are
Expand Down Expand Up @@ -421,15 +449,11 @@ async function synthesizeUntrackedHunk(
} catch {
return null;
}
let fh;
try {
// O_NOFOLLOW closes the TOCTOU window between the lstat above and the
// open — where the flag does not exist (Windows) the helper compensates
// with an identity re-check (#8227).
fh = await openNoFollow(absPath);
} catch {
return null;
}
// O_NOFOLLOW closes the TOCTOU window between the lstat above and the
// open — where the flag does not exist (Windows) the helper compensates
// with an identity re-check (#8227).
const fh = await openUntrackedForDiffRead(absPath);
if (!fh) return null;
try {
const st = await fh.stat();
if (!st.isFile()) return null;
Expand Down Expand Up @@ -943,13 +967,11 @@ async function countUntrackedLines(
if (!st.isFile()) {
return { added: 0, isBinary: true, truncated: false };
}
let fh;
try {
// O_NOFOLLOW closes the TOCTOU window between the lstat above and the
// open — where the flag does not exist (Windows) the helper compensates
// with an identity re-check (#8227).
fh = await openNoFollow(absPath);
} catch {
// O_NOFOLLOW closes the TOCTOU window between the lstat above and the
// open — where the flag does not exist (Windows) the helper compensates
// with an identity re-check (#8227).
const fh = await openUntrackedForDiffRead(absPath);
if (!fh) {
// ELOOP from O_NOFOLLOW (path raced into a symlink between lstat and
// open) and any other open error all collapse to a binary row so the
// file appears once in the listing without contributing line counts.
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/utils/no-follow-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { openNoFollow, openSyncNoFollow } from './no-follow-open.js';
import {
isUnverifiableIdentityError,
openNoFollow,
openSyncNoFollow,
UNVERIFIABLE_IDENTITY_CODE,
} from './no-follow-open.js';

let tmpDirs: string[] = [];

Expand Down Expand Up @@ -234,9 +239,22 @@ describe('openNoFollow without O_NOFOLLOW (Windows flag set)', () => {
const { openSyncNoFollow: openSyncFallback } = await import(
'./no-follow-open.js'
);
expect(() => openSyncFallback(filePath)).toThrow(
expect.objectContaining({ code: 'ELOOP' }),
);
// Distinct from a genuine symlink refusal: the code must NOT be
// 'ELOOP', or consumers' ELOOP-specific handling (symlink-escape
// flags, "not a regular file" errors, binary-row collapses) misfires
// on legitimate files that merely live on an inode-0 volume.
const error = (() => {
try {
openSyncFallback(filePath);
return undefined;
} catch (e) {
return e as NodeJS.ErrnoException;
}
})();
expect(error).toBeDefined();
expect(error?.code).toBe(UNVERIFIABLE_IDENTITY_CODE);
expect(error?.code).not.toBe('ELOOP');
expect(isUnverifiableIdentityError(error)).toBe(true);
} finally {
vi.doUnmock('node:fs');
vi.resetModules();
Expand Down
44 changes: 39 additions & 5 deletions packages/core/src/utils/no-follow-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@
* there rather than degrade to a plain open — the same fail-closed posture
* used for unverifiable inode identities elsewhere (#8290, #9857).
*
* Every refusal is reported as an error with `code: 'ELOOP'` — the same
* code POSIX `O_NOFOLLOW` produces — so existing `ELOOP` handling in
* callers applies to the fallback path unchanged.
* Symlink refusals and identity races are reported as errors with
* `code: 'ELOOP'` — the same code POSIX `O_NOFOLLOW` produces — so
* existing `ELOOP` handling in callers applies to the fallback path
* unchanged. The inode-0 refusal, where identity was never provable in the
* first place, carries {@link UNVERIFIABLE_IDENTITY_CODE} instead, so a
* legitimate file on an inode-0 volume is not misclassified by
* ELOOP-specific handling as a symlink escape.
*
* `node:fs` is bound through the DEFAULT import (not a namespace import)
* so suites that spy the fs object — the way `sessionService.rename.test.ts`
Expand All @@ -45,21 +49,50 @@ import type { FileHandle } from 'node:fs/promises';

import { hasVerifiableInode } from './file-identity.js';

/**
* Error code carried by the refusal raised when the fallback cannot even
* attempt the identity proof — the filesystem reports inode 0 (FAT/exFAT,
* some SMB shares), so the opened file cannot be proven identical to the
* one the pre-open check saw.
*
* The refusal itself is the documented fail-closed posture (#8290, #9857).
* What must stay distinguishable is the reason: callers with ELOOP-specific
* handling (symlink-escape flags, "not a regular file" errors, binary-row
* collapses) would otherwise misfire on LEGITIMATE files that merely live
* on an inode-0 volume. Genuine symlink refusals and identity races keep
* `code: 'ELOOP'`.
*/
export const UNVERIFIABLE_IDENTITY_CODE = 'EUNVERIFIABLE';

/**
* True iff `error` is the inode-unverifiable refusal described by
* {@link UNVERIFIABLE_IDENTITY_CODE}.
*/
export function isUnverifiableIdentityError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as NodeJS.ErrnoException).code === UNVERIFIABLE_IDENTITY_CODE
);
}

function noFollowRejection(
filePath: string,
reason: string,
code: string = 'ELOOP',
): NodeJS.ErrnoException {
const error = new Error(
`Refusing to open '${filePath}' without a no-follow guarantee: ${reason}`,
) as NodeJS.ErrnoException;
error.code = 'ELOOP';
error.code = code;
return error;
}

/**
* Verify that the handle opened in step (2) still refers to the file seen by
* the pre-open `lstat` (step 1). Throws an `ELOOP`-coded error when the
* identity cannot be proven or has changed.
* identity changed, and an {@link UNVERIFIABLE_IDENTITY_CODE}-coded error
* when it was never provable (inode 0).
*/
function assertSameIdentity(
filePath: string,
Expand All @@ -71,6 +104,7 @@ function assertSameIdentity(
filePath,
'the filesystem reports inode 0, so the opened file cannot be ' +
Comment thread
yiliang114 marked this conversation as resolved.
'proven identical to the one that was checked',
UNVERIFIABLE_IDENTITY_CODE,
);
}
if (before.dev !== after.dev || before.ino !== after.ino) {
Expand Down