From e15a6168d2c8d69ae004feff822c89b2adc78c36 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 04:31:22 +0800 Subject: [PATCH 01/46] Support durable JSONL persistence on Windows --- docs/rfc/INDEX.md | 1 + ...026-07-05-windows-jsonl-durable-publish.md | 33 ++++ .../session-persistence-jsonl/README.md | 4 +- .../session-persistence-jsonl/package.json | 1 + .../session-persistence-jsonl/src/index.ts | 128 +++++++++---- .../session-persistence-jsonl/src/win32.ts | 150 ++++++++++++++++ .../tests/jsonl.spec.ts | 39 ++++ .../tests/win32.spec.ts | 168 ++++++++++++++++++ pnpm-lock.yaml | 144 +++++++++++++++ pnpm-workspace.yaml | 2 + 10 files changed, 636 insertions(+), 34 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md create mode 100644 packages/session-persistence/session-persistence-jsonl/src/win32.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ab39d4f314..32f11b744a 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -150,6 +150,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | | [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 | | [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | +| [Windows-native durable JSONL publication](implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md) | 2026-07-05 | | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool result retention library](implemented/architecture/2026-07-06-tool-result-retention-library.md) | 2026-07-06 | | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | diff --git a/docs/rfc/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/docs/rfc/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md new file mode 100644 index 0000000000..909f75132f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md @@ -0,0 +1,33 @@ +# RFC: Windows-native durable JSONL publication + +Status: implemented + +## Problem + +`dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized. + +Windows has atomic namespace operations, but Node does not expose a POSIX-equivalent parent-directory fsync contract there. Treating Windows directory sync failures as success would silently weaken a durable backend. The Windows path therefore needs a different publication primitive rather than a conditional inside the POSIX `syncDir` helper. + +## Decision + +The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and initial JSONL bytes; POSIX and Windows then run separate publication protocols. + +POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link. + +Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. + +## Alternatives considered + +**Ignore Windows directory-sync failures.** Rejected because it reports a first append as durable without forcing the published namespace entry to stable storage. + +**Use `CreateHardLinkW`.** Rejected because hard links are filesystem-dependent, do not publish directories, and expose no write-through option. + +**Use replacement or transactional APIs.** `ReplaceFileW` has replacement semantics that conflict with same-id collision rejection, and Transactional NTFS is not recommended for new application designs. + +## Consequences + +The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and on-disk JSONL format do not change. + +Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally. + +Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles. diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 43f4443107..097ad04b67 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics -- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. +- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically on the first `append`: POSIX uses temp-write + file `fsync` + `link` + parent-directory `fsync`; Windows uses temp-write + file `fsync` + `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through publish pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. @@ -45,4 +45,4 @@ The plugin buffers frozen session events and drains them on flush or disposal. A - **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. -- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. +- **POSIX materialization requires hard-link support** — its first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index ddb9f2af4d..2644e14efc 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -27,6 +27,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "koffi": "^3.1.0", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 0c8a2ee3ef..ac1c07860e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -19,6 +19,7 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, } from './format.ts' +import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' /** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */ export interface Config { @@ -160,33 +161,35 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- materialization / append / repair (file mechanics) --- - /** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */ + /** Atomically write the header line + first batch (temp-write, fsync, publish). */ private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const dir = sessionDir(this.root, meta.cwd) - await mkdir(this.root, { recursive: true, mode: 0o700 }) - await this.syncDir(dirname(this.root)) - await mkdir(dir, { recursive: true, mode: 0o700 }) - await this.syncDir(this.root) const finalPath = logPath(this.root, meta.cwd, meta.id) - // Materialization is the first write; an existing log is an id collision. - /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ - if (await this.exists(finalPath)) { - throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`) + const content = this.initialLogContent(meta, events) + /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */ + if (process.platform === 'win32') { + await this.materializeWin32(dir, finalPath, meta.id, content) + } else { + await this.materializePosix(dir, finalPath, meta.id, content) } + } + + private initialLogContent(meta: SessionHeader, events: readonly SessionEvent[]): string { const header = JSON.stringify(toHeaderLine(meta)) const body = events.map(eventLine).join('\n') - const content = header + '\n' + body + '\n' + return header + '\n' + body + '\n' + } - const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp` - const handle = await open(tmp, 'wx', 0o600) - try { - await handle.writeFile(content) - await handle.sync() - } finally { - await handle.close() - } - // Publish with link()+unlink(): unlike rename(), link fails if another - // process materialized the same id first. + private async materializePosix(dir: string, finalPath: string, id: SessionId, content: string): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + await this.syncDirPosix(dirname(this.root)) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await this.syncDirPosix(this.root) + await this.rejectExistingLog(finalPath, id) + const tmp = await this.writeSyncedTempFile(finalPath, content) + // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the + // final path already exists, so two processes materializing the same id + // concurrently cannot clobber each other. rename() would silently overwrite. let linked = false try { await link(tmp, finalPath) @@ -197,10 +200,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ if (!linked) await rm(tmp, { force: true }) } - // The published link becomes crash-durable only after its directory fsync. - await this.syncDir(dir) - // Best-effort temp cleanup: the log is already published and durable, so a failure to - // remove the (now-redundant) temp hard link must not reject the append. + // link() succeeded — the log is published. fsync the directory so the new + // entry survives a power loss: the new link is not crash-durable until the + // parent directory's metadata is synced. + await this.syncDirPosix(dir) + // Best-effort temp cleanup: the log is already published and durable, so a + // failure to remove the (now-redundant) temp hard link must NOT reject the + // append. Swallow only the rm failure; nothing else of consequence runs here. try { await rm(tmp, { force: true }) } catch { @@ -208,8 +214,47 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** fsync a directory so a just-created or published entry inside it is crash-durable. */ - private async syncDir(dir: string): Promise { + /* v8 ignore start -- native Windows coverage exercises this integration path */ + private async materializeWin32(dir: string, finalPath: string, id: SessionId, content: string): Promise { + await ensureDurableDirectoryWin32(this.root) + await ensureDurableDirectoryWin32(dir) + await this.rejectExistingLog(finalPath, id) + const tmp = await this.writeSyncedTempFile(finalPath, content) + try { + await publishNewFileWin32(tmp, finalPath) + } catch (error) { + await rm(tmp, { force: true }) + throw error + } + } + /* v8 ignore stop */ + + private async rejectExistingLog(finalPath: string, id: SessionId): Promise { + // Never publish over an existing committed log: materialize is the FIRST + // write of a session the backend believes is new. A file here means a + // different session shares this id on disk — reject loudly. (createCore + // already guards the create path, so this is unreachable-in-practice TOCTOU + // defense.) + /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ + if (await this.exists(finalPath)) { + throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`) + } + } + + private async writeSyncedTempFile(finalPath: string, content: string): Promise { + const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp` + const handle = await open(tmp, 'wx', 0o600) + try { + await handle.writeFile(content) + await handle.sync() + } finally { + await handle.close() + } + return tmp + } + + /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */ + private async syncDirPosix(dir: string): Promise { const handle = await open(dir, 'r') try { await handle.sync() @@ -226,17 +271,37 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const path = logPath(this.root, meta.cwd, meta.id) const handle = await open(path, 'a') + let closed = false + const closeAppendHandle = async (): Promise => { + if (closed) return + closed = true + await handle.close() + } + try { const { size: before } = await handle.stat() try { await handle.writeFile(events.map(eventLine).join('\n') + '\n') await handle.sync() } catch (error) { - // Roll back whatever bytes landed so a retry starts from a clean EOF. - await handle.truncate(before) - await handle.sync() + try { + await closeAppendHandle() + await this.rollbackAppend(path, before) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`) + } throw error } + } finally { + await closeAppendHandle() + } + } + + private async rollbackAppend(path: string, size: number): Promise { + const handle = await open(path, 'r+') + try { + await handle.truncate(size) + await handle.sync() } finally { await handle.close() } @@ -322,9 +387,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await handle.close() return true } catch (error) { - // Only ENOENT means absent. A permission/I/O error must surface, not be - // collapsed to `false` — otherwise load() reports "not found" and collision - // checks proceed under a false absence assumption. + // Only ENOENT means absent. A permission/I/O error must surface rather + // than letting load or collision checks proceed under false absence. if (isENOENT(error)) return false throw error } diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts new file mode 100644 index 0000000000..143f230ea3 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -0,0 +1,150 @@ +/** + * Windows durable namespace helpers for the JSONL backend. + * + * POSIX publishes a newly-created log by creating a directory entry and then + * fsyncing the parent directory. Windows does not expose that parent-directory + * fsync contract through Node, so the Windows path uses the native durable + * namespace primitive instead: create a staging object in the target directory + * and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without + * replacement or cross-volume copy fallback. + * + * @module dsh-session-persistence-jsonl/win32 + */ + +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { basename, join, parse, resolve, toNamespacedPath } from 'node:path' + +type MoveFileExW = (existing: string, replacement: string, flags: number) => boolean +type GetLastError = () => number + +interface Win32Bindings { + moveFileExW: MoveFileExW + getLastError: GetLastError +} + +interface Win32ErrnoException extends NodeJS.ErrnoException { + win32Code: number + dest: string +} + +const MOVEFILE_WRITE_THROUGH = 0x00000008 +const ERROR_FILE_NOT_FOUND = 2 +const ERROR_PATH_NOT_FOUND = 3 +const ERROR_ACCESS_DENIED = 5 +const ERROR_NOT_SAME_DEVICE = 17 +const ERROR_FILE_EXISTS = 80 +const ERROR_INVALID_NAME = 123 +const ERROR_ALREADY_EXISTS = 183 + +let bindings: Win32Bindings | undefined + +/** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */ +async function win32(): Promise { + if (bindings !== undefined) return bindings + const koffi = (await import('koffi')).default + const kernel32 = koffi.load('kernel32.dll') + bindings = { + moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'bool', ['str16', 'str16', 'uint']) as MoveFileExW, + getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError, + } + return bindings +} + +function errnoCode(win32Code: number): string { + switch (win32Code) { + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + return 'ENOENT' + case ERROR_ACCESS_DENIED: + return 'EACCES' + case ERROR_NOT_SAME_DEVICE: + return 'EXDEV' + case ERROR_FILE_EXISTS: + case ERROR_ALREADY_EXISTS: + return 'EEXIST' + case ERROR_INVALID_NAME: + return 'EINVAL' + default: + return 'EIO' + } +} + +function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException { + const code = errnoCode(win32Code) + const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException + error.code = code + error.errno = win32Code + error.syscall = syscall + error.path = path + error.dest = dest + error.win32Code = win32Code + return error +} + +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +async function assertDirectory(path: string): Promise { + try { + const info = await stat(path) + if (info.isDirectory()) return true + const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException + error.code = 'ENOTDIR' + error.path = path + throw error + } catch (error) { + if (isENOENT(error)) return false + throw error + } +} + +/** + * Publish `existing` at `replacement` with Windows write-through rename + * semantics. The destination must not already exist; the move must stay within + * the volume (no copy fallback flag is set). + * @param existing - the synced staging path to move. + * @param replacement - the final path, which must not already exist. + */ +export async function publishNewFileWin32(existing: string, replacement: string): Promise { + const api = await win32() + const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) + if (!ok) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) +} + +/** + * Create `target` and its missing ancestors with durable Windows namespace + * publication. Each missing directory is first created as a random staging + * sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races + * with another creator are accepted only after verifying the winner is a + * directory. + * @param target - the absolute directory path to create durably when absent. + */ +export async function ensureDurableDirectoryWin32(target: string): Promise { + const absolute = resolve(target) + const root = parse(absolute).root + await assertDirectory(root) + + const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0) + let current = root + for (const segment of segments) { + const next = join(current, segment) + if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next) + current = next + } +} + +async function createLeafDirectoryWin32(parent: string, target: string): Promise { + const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`)) + try { + await publishNewFileWin32(staging, target) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + if (isEEXIST(error) && await assertDirectory(target)) return + throw error + } +} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 4b279e3c6e..e7a65d3c8f 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -336,6 +336,45 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) + it('reports both the append failure and a failed rollback', async () => { + const m = meta('rollback-failure') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + const path = logPath(root, undefined, m.id) + const handle = await (await import('node:fs/promises')).open(path, 'r') + const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } + await handle.close() + const realSync = proto.sync + let failed = false + const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) { + if (!failed) { failed = true; throw new Error('simulated append fsync failure') } + return realSync.call(this) + }) + const backend = ctx.sessionPersistence as unknown as { + rollbackAppend: (path: string, size: number) => Promise + } + const realRollback = backend.rollbackAppend.bind(backend) + backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure')) + + try { + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] as SessionEvent[]) + throw new Error('expected append to reject') + } catch (error) { + expect(error).toBeInstanceOf(AggregateError) + const aggregate = error as AggregateError + expect(aggregate.message).toContain(`failed to roll back append to "${path}"`) + expect(aggregate.errors).toHaveLength(2) + expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' }) + expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' }) + } finally { + backend.rollbackAppend = realRollback + syncSpy.mockRestore() + } + }) + it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { const m = meta('meta-copy', '/proj') await ctx.sessionPersistence.create(m) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts new file mode 100644 index 0000000000..760eb3d455 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts @@ -0,0 +1,168 @@ +/** + * Unit tests for the Windows durable namespace helper with a mocked kernel32 + * binding. The real JSONL suite exercises the helper on native Windows; these + * tests keep the Win32 error mapping and race handling covered on every host. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const MOVEFILE_WRITE_THROUGH = 0x00000008 +const ERROR_FILE_NOT_FOUND = 2 +const ERROR_PATH_NOT_FOUND = 3 +const ERROR_ACCESS_DENIED = 5 +const ERROR_NOT_SAME_DEVICE = 17 +const ERROR_FILE_EXISTS = 80 +const ERROR_INVALID_NAME = 123 +const ERROR_ALREADY_EXISTS = 183 + +type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => boolean + +const roots: string[] = [] + +function stripNamespace(path: string): string { + if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}` + if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length) + return path +} + +async function tempRoot(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-')) + roots.push(dir) + return dir +} + +async function importWithMove(moveFileExW: MoveFileExW): Promise { + vi.resetModules() + vi.doMock('koffi', () => { + let lastError = 0 + const setLastError = (code: number): void => { lastError = code } + const move: MoveFileExW = (existing, replacement, flags, setError) => { + const ok = moveFileExW(existing, replacement, flags, setError) + lastError = ok ? 0 : lastError + return ok + } + return { + default: { + load: () => ({ + func: (_convention: string, name: string) => { + if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => { + const ok = move(existing, replacement, flags, setLastError) + return ok + } + return () => lastError + }, + }), + }, + } + }) + return import('../src/win32.ts') +} + +async function importWithError(code: number): Promise { + vi.resetModules() + vi.doMock('koffi', () => ({ + default: { + load: () => ({ + func: (_convention: string, name: string) => { + if (name === 'MoveFileExW') return () => false + return () => code + }, + }), + }, + })) + return import('../src/win32.ts') +} + +async function importWithFilesystemMove(): Promise { + return importWithMove((existing, replacement, flags, setLastError) => { + expect(flags).toBe(MOVEFILE_WRITE_THROUGH) + const from = stripNamespace(existing) + const to = stripNamespace(replacement) + if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return false } + if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return false } + renameSync(from, to) + return true + }) +} + +afterEach(async () => { + vi.doUnmock('koffi') + vi.resetModules() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +describe('Windows durable namespace helpers', () => { + it('publishes a new file with write-through MoveFileExW semantics', async () => { + const { publishNewFileWin32 } = await importWithFilesystemMove() + const root = await tempRoot() + const tmp = join(root, 'log.tmp') + const final = join(root, 'log.jsonl') + await writeFile(tmp, 'content') + + await publishNewFileWin32(tmp, final) + expect(existsSync(tmp)).toBe(false) + expect(readFileSync(final, 'utf8')).toBe('content') + }) + + it('maps Win32 publish failures to Node-style errno codes', async () => { + const cases = [ + [ERROR_FILE_NOT_FOUND, 'ENOENT'], + [ERROR_PATH_NOT_FOUND, 'ENOENT'], + [ERROR_ACCESS_DENIED, 'EACCES'], + [ERROR_NOT_SAME_DEVICE, 'EXDEV'], + [ERROR_FILE_EXISTS, 'EEXIST'], + [ERROR_ALREADY_EXISTS, 'EEXIST'], + [ERROR_INVALID_NAME, 'EINVAL'], + [9999, 'EIO'], + ] as const + for (const [win32Code, code] of cases) { + const { publishNewFileWin32 } = await importWithError(win32Code) + await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' }) + } + }) + + it('creates missing directories through staging siblings and tolerates an already-created race', async () => { + const root = await tempRoot() + const raced = join(root, 'raced') + const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => { + expect(flags).toBe(MOVEFILE_WRITE_THROUGH) + const from = stripNamespace(existing) + const to = stripNamespace(replacement) + if (to === raced) { + mkdirSync(to) + setLastError(ERROR_ALREADY_EXISTS) + return false + } + if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return false } + if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return false } + renameSync(from, to) + return true + }) + + await ensureDurableDirectoryWin32(join(root, 'a', 'b')) + expect(existsSync(join(root, 'a', 'b'))).toBe(true) + await ensureDurableDirectoryWin32(join(root, 'a', 'b')) + await ensureDurableDirectoryWin32(raced) + expect(existsSync(raced)).toBe(true) + }) + + it('surfaces directory publication failures other than an existing-target race', async () => { + const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED) + const root = await tempRoot() + + await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' }) + }) + + it('rejects a non-directory component instead of treating it as missing', async () => { + const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove() + const root = await tempRoot() + const blocked = join(root, 'blocked') + writeFileSync(blocked, 'x') + + await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9810a9a61e..9e6532f466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1259,6 +1259,9 @@ importers: packages/session-persistence/session-persistence-jsonl: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3453,6 +3456,81 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@koromix/koffi-darwin-arm64@3.1.1': + resolution: {integrity: sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.1.1': + resolution: {integrity: sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.1.1': + resolution: {integrity: sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.1.1': + resolution: {integrity: sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.1.1': + resolution: {integrity: sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.1.1': + resolution: {integrity: sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-ia32@3.1.1': + resolution: {integrity: sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.1.1': + resolution: {integrity: sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.1.1': + resolution: {integrity: sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.1.1': + resolution: {integrity: sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.1.1': + resolution: {integrity: sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.1.1': + resolution: {integrity: sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.1.1': + resolution: {integrity: sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.1.1': + resolution: {integrity: sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.1.1': + resolution: {integrity: sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==} + cpu: [x64] + os: [win32] + '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} @@ -5662,6 +5740,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + koffi@3.1.1: + resolution: {integrity: sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==} + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -7842,6 +7923,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@koromix/koffi-darwin-arm64@3.1.1': + optional: true + + '@koromix/koffi-darwin-x64@3.1.1': + optional: true + + '@koromix/koffi-freebsd-arm64@3.1.1': + optional: true + + '@koromix/koffi-freebsd-ia32@3.1.1': + optional: true + + '@koromix/koffi-freebsd-x64@3.1.1': + optional: true + + '@koromix/koffi-linux-arm64@3.1.1': + optional: true + + '@koromix/koffi-linux-ia32@3.1.1': + optional: true + + '@koromix/koffi-linux-loong64@3.1.1': + optional: true + + '@koromix/koffi-linux-riscv64@3.1.1': + optional: true + + '@koromix/koffi-linux-x64@3.1.1': + optional: true + + '@koromix/koffi-openbsd-ia32@3.1.1': + optional: true + + '@koromix/koffi-openbsd-x64@3.1.1': + optional: true + + '@koromix/koffi-win32-arm64@3.1.1': + optional: true + + '@koromix/koffi-win32-ia32@3.1.1': + optional: true + + '@koromix/koffi-win32-x64@3.1.1': + optional: true + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -10072,6 +10198,24 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + koffi@3.1.1: + optionalDependencies: + '@koromix/koffi-darwin-arm64': 3.1.1 + '@koromix/koffi-darwin-x64': 3.1.1 + '@koromix/koffi-freebsd-arm64': 3.1.1 + '@koromix/koffi-freebsd-ia32': 3.1.1 + '@koromix/koffi-freebsd-x64': 3.1.1 + '@koromix/koffi-linux-arm64': 3.1.1 + '@koromix/koffi-linux-ia32': 3.1.1 + '@koromix/koffi-linux-loong64': 3.1.1 + '@koromix/koffi-linux-riscv64': 3.1.1 + '@koromix/koffi-linux-x64': 3.1.1 + '@koromix/koffi-openbsd-ia32': 3.1.1 + '@koromix/koffi-openbsd-x64': 3.1.1 + '@koromix/koffi-win32-arm64': 3.1.1 + '@koromix/koffi-win32-ia32': 3.1.1 + '@koromix/koffi-win32-x64': 3.1.1 + layout-base@1.0.2: {} layout-base@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f93814899..2de5578f5d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,6 +31,8 @@ allowBuilds: '@google/genai': false protobufjs: false node-addon-require-builtin: false + # JSONL durability calls MoveFileExW with write-through publication on Windows. + koffi: true # The Landlock launcher family is our own sibling-repo release, consumed # fresh (hours old at each coordinated bump) — the release-age quarantine From 5ed34b66ce0d688a2440b42a25db99a7c71330de Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 8 Jul 2026 12:45:31 +0800 Subject: [PATCH 02/46] fix(acp-snapshot): make path-separator tests platform-neutral Three tests in the shared acp-snapshot package hardcoded POSIX path separators in their assertions, so they failed on Windows where node:path.join produces backslash paths: - childFixturePaths (suite.spec.ts): expected literal '/snap/s/session.1.jsonl' but join returns '\snap\s\...' on Windows; use join() for the expected value. - harness.spec.ts (env-forwarding test): substring-matched a JSON-encoded path against raw stdout text, where backslash escaping makes the compare byte-fragile; parse the env-probe chunk and compare the structured value. - harness.spec.ts (harvested-cwd test): substring-matched the raw cwd against JSONL text where the cwd is JSON-escaped; parse the session line and compare the cwd field. These were master's latent bugs (the package's tests never ran on Windows until the Windows CI lane observed them). Verified green on Windows via scripts/caohuanqi-private/run-ci.py --windows. --- .../support/acp-snapshot/tests/harness.spec.ts | 18 ++++++++++++++++-- .../support/acp-snapshot/tests/suite.spec.ts | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 1d953f5e95..d141fb1f09 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -72,7 +72,11 @@ describe('runScenario', () => { expect(result.sessionLogs[0]?.createdAt).toBe(42) expect(result.sessionLogs[0]?.content).toContain('turn/start') // The harvested log embeds the run's REAL temp cwd (template-substituted). - expect(result.sessionLogs[0]?.content).toContain(result.cwd) + // The cwd is JSON-encoded in the log line, so compare the parsed field + // rather than substring-matching a raw path (which breaks when the path + // separator is escaped inside JSON text on Windows). + const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}' + expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd) }) it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => { @@ -93,7 +97,17 @@ describe('runScenario', () => { expect(result.stderr).toContain('fake bin booted') expect(result.rawStdout).toContain('replay.override.json') // Child paths ride one env var, joined with the platform delimiter. - expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1)) + // Parse the fake bin's env-probe chunk rather than substring-matching a + // JSON-encoded path (the escaping breaks raw-substring compares on Windows). + const envChunk = result.rawStdout.split('\n') + .map(l => l.trim()) + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } }) + .find(f => f.params?.update?.content?.text?.startsWith('env:')) + const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as { + childFiles: string | null + } + expect(env.childFiles).toBe(childFiles.join(delimiter)) }) it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => { diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4cedc1cdfb..c2007321de 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -181,7 +181,7 @@ describe('defineAcpSnapshotSuite: registration contract', () => { describe('childFixturePaths', () => { it('yields one sibling path per child, 1-based', () => { - expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl']) + expect(childFixturePaths('/snap/s', 2)).toEqual([join('/snap/s', 'session.1.jsonl'), join('/snap/s', 'session.2.jsonl')]) }) it('yields nothing for a single-session scenario', () => { From 1a4af034c0edd60e4bd54de1f2a1371afbdef056 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 04:45:41 +0800 Subject: [PATCH 03/46] Accept native ACP path separators in tests --- packages/ui/acp/README.md | 4 +- packages/ui/acp/tests/stream-update.spec.ts | 53 ++++++++++++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 300cd69023..4f810ba0b4 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -53,11 +53,11 @@ The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictabl ## Tool-call presentation -Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation). +Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation). ## Terminal card (capability-gated) -When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). +When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). ## Settle-exactly-once diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 415e9afb33..15a6d3c3fc 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { join as pathJoin, resolve as pathResolve } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -49,6 +50,16 @@ function evt(type: T, data: Extract { it('maps assistant/chunk text-delta to agent_message_chunk', () => { expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }))) @@ -480,10 +491,10 @@ describe('terminal-card mapping (capability-gated)', () => { it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => { const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs') - const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent) + const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent) // Relative workdir resolved against the session cwd — the card header matches // where execution actually ran (tool-bash resolves the same way). - expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir') + expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir')) // No session cwd to resolve against → the relative tool cwd is passed through as-is. const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only') @@ -675,10 +686,12 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo // paths remain absolute so the editor can open the real file. const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) - const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' }) - const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } + const workspace = nativeAbsolute('/work/proj') + const file = nativeAbsolute('/work/proj', 'src', 'b.ts') + const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' }) + const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } const out: SessionNotification['update'][] = [] - const rendering = { enabled: false, cwd: '/work/proj' } + const rendering = { enabled: false, cwd: workspace } for (const event of [ evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), @@ -687,8 +700,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo sessionUpdate: 'tool_call_update', toolCallId: 'e1', status: 'completed', - title: 'Edit src/b.ts', - content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], + title: `Edit ${nativePath('src', 'b.ts')}`, + content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], }) await ctx.fiber.dispose() }) @@ -739,21 +752,25 @@ describe('relative-path display titles (bridge relativizes the title against the it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => { const ctx = await fsCtx() - const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 }) + const workspace = nativeAbsolute('/work/proj') + const file = nativeAbsolute('/work/proj', 'src', 'a.ts') + const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 }) expect(update).toMatchObject({ - title: 'Read src/a.ts (from line 5)', - locations: [{ path: '/work/proj/src/a.ts', line: 5 }], + title: `Read ${nativePath('src', 'a.ts')} (from line 5)`, + locations: [{ path: file, line: 5 }], }) await ctx.fiber.dispose() }) it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => { const ctx = await fsCtx() - const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' }) + const workspace = nativeAbsolute('/work/proj') + const file = nativeAbsolute('/work/proj', 'src', 'b.ts') + const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' }) expect(update).toMatchObject({ - title: 'Edit src/b.ts', - locations: [{ path: '/work/proj/src/b.ts' }], - content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }], + title: `Edit ${nativePath('src', 'b.ts')}`, + locations: [{ path: file }], + content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }], }) await ctx.fiber.dispose() }) @@ -770,8 +787,8 @@ describe('relative-path display titles (bridge relativizes the title against the // with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it, // matching targets under `cwd + sep` in the reference adapter. const ctx = await fsCtx() - const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' }) - expect((update as { title: string }).title).toBe('Read ..cache/x.ts') + const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') }) + expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`) await ctx.fiber.dispose() }) @@ -784,8 +801,8 @@ describe('relative-path display titles (bridge relativizes the title against the it('a relative path is passed through unchanged (already display-friendly)', async () => { const ctx = await fsCtx() - const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' }) - expect((update as { title: string }).title).toBe('Read src/a.ts') + const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') }) + expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`) await ctx.fiber.dispose() }) }) From 130944caeb8118033f5de86f4a1df1c85e962595 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 17:47:24 +0800 Subject: [PATCH 04/46] Close SQLite probe handle after journalMode assertion --- .../session-persistence-sqlite/tests/sqlite.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a728cc0b71..cebfae09a4 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -423,7 +423,9 @@ describe('SessionPersistenceSqlite: edge cases', () => { const walPath = await freshDbPath() const bWal = await backend(walPath) await bWal.ctx.sessionPersistence.create(meta('jm-wal')) - expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal') + const probe = openDatabase(walPath, 'wal') + expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal') + probe.close() await bWal.dispose() const deletePath = await freshDbPath() From 715aa7372a0ab51c5780a4d014009138da5f1444 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 18:57:23 +0800 Subject: [PATCH 05/46] Restore ENOTDIR semantic distinction in resolveLocalTarget on Windows --- packages/fs/fs-local/src/fsio.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 360145e8c8..e0745f1735 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -145,8 +145,22 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise Date: Sun, 5 Jul 2026 21:15:46 +0800 Subject: [PATCH 06/46] fs-local: POSIX-only mode-bit assertions, document Windows DACL-inheritance semantics Windows drives only the read-only attribute through chmod and reports synthetic stat mode bits, so writeFileAtomic's mode arguments are inert there; write-in-progress privacy comes from the staging dir (created in the target's parent) inheriting the destination directory's DACL. Production is deliberately unchanged -- the chmod calls are benign no-ops and platform-guarding them out buys nothing. Tests guard the mode-bit expects to POSIX; there is no Windows ACL assertion because an ACL check would pin OS inheritance plus the machine's %TEMP% ACL, not this package. Decision and rejected alternatives (explicit DACLs, Get-Acl/icacls test verification) recorded in the new RFC. --- docs/rfc/INDEX.md | 1 + .../2026-07-05-windows-fs-permissions.md | 29 +++++++++++++++++++ packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 4 ++- packages/fs/fs-local/tests/fsio.spec.ts | 19 +++++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 32f11b744a..5ebf52f07a 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -150,6 +150,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | | [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 | | [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | +| [Windows write-permission semantics — inherited DACLs, not mode bits](implemented/architecture/2026-07-05-windows-fs-permissions.md) | 2026-07-05 | | [Windows-native durable JSONL publication](implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md) | 2026-07-05 | | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool result retention library](implemented/architecture/2026-07-06-tool-result-retention-library.md) | 2026-07-06 | diff --git a/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md new file mode 100644 index 0000000000..0001e8223c --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md @@ -0,0 +1,29 @@ +# RFC: Windows write-permission semantics — inherited DACLs, not mode bits + +Status: implemented + +## Problem + +`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions. + +Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL, which this code never sets; a newly created file or directory inherits its DACL from its parent directory. + +## Decision + +Production code is unchanged: no platform fork, no DACL management. The Windows privacy invariant is structural rather than mode-driven — the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit exactly the destination directory's DACL, and write-in-progress content is never exposed more widely than the destination itself. In the typical deployment (a coding agent writing the user's own project tree under `C:\Users\\`) the inherited DACL is owner + SYSTEM + Administrators, matching the POSIX intent. + +Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion because there is no Windows-side code behavior to pin: an ACL check on a `mkdtemp(tmpdir())` fixture would verify Windows DACL inheritance plus the machine's `%TEMP%` ACL — the operating system, not this package — and no change to this package could turn it red. + +## Alternatives considered + +**Explicit protected DACLs.** Granting owner-only access would require per-write FFI or a subprocess, break inheritance, and surprise users whose project directories are deliberately shared. This becomes appropriate only if the threat model includes hostile local readers of broadly accessible target directories. + +**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile. + +**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior. + +## Consequences + +POSIX keeps the stronger guarantee: owner-only temp content regardless of the parent directory. Windows guarantees only "no wider than the destination": a target inside a broadly accessible directory (a share, a permissive `D:\` root) gets equally accessible write-in-progress content. The gap is deliberate and documented, not an oversight. + +Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced at all there — `rename` over it fails before the preserved mode would matter. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 65ed76efce..7c3899e5cb 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows the mode bits drive only the read-only attribute, and write-in-progress privacy comes instead from the staging dir inheriting the destination directory's DACL ([Windows write-permission RFC](../../../docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index e0745f1735..4603611ce4 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -408,9 +408,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow /** * Atomically replace a file through a private, synced staging file in the same directory. + * POSIX protects the staging directory and file with `0o700` and `0o600`; Windows + * inherits the destination directory's DACL because Node mode bits are synthetic there. * @param absolutePath - destination; missing parent directories are created. * @param content - the full UTF-8 text to write. - * @param mode - final mode, or `0o600` when omitted. + * @param mode - final POSIX mode, or `0o600` when omitted; inert on Windows. * @param signal - cancellation checked before the final rename. * @param internals - test seam for pinning temp names and observing the staged file. */ diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 199c01f411..559ef86563 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -367,6 +367,12 @@ describe('streamWholeText', () => { }) }) +// Windows drives only the read-only attribute through `chmod` and reports +// synthetic `stat` mode bits, so mode assertions are POSIX-only; on Windows +// write-in-progress privacy comes from the destination directory's inherited +// DACL (docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md). +const posixModes = process.platform !== 'win32' + describe('writeFileAtomic — temp-file safety', () => { it('writes through a private staging dir and owner-only temp file', async () => { const file = join(dir, 'a.txt') @@ -374,17 +380,22 @@ describe('writeFileAtomic — temp-file safety', () => { await writeFileAtomic(file, 'hello', 0o640, undefined, { inspectTemp: async ({ stagingDir, tempPath }) => { inspected = true - expect((await stat(stagingDir)).mode & 0o777).toBe(0o700) - expect((await stat(tempPath)).mode & 0o777).toBe(0o600) + const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)]) + expect(staging.isDirectory()).toBe(true) + expect(temp.isFile()).toBe(true) + if (posixModes) { + expect(staging.mode & 0o777).toBe(0o700) + expect(temp.mode & 0o777).toBe(0o600) + } }, }) expect(inspected).toBe(true) expect(await readFile(file, 'utf8')).toBe('hello') - expect((await stat(file)).mode & 0o777).toBe(0o640) + if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640) expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) - it('creates new files owner-only by default', async () => { + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined) expect((await stat(file)).mode & 0o777).toBe(0o600) From 5a2ca3ff3aaa6b3410572c9a443ca98625b557ad Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 17 Jul 2026 15:43:44 +0800 Subject: [PATCH 07/46] Restore JSONL ENOTDIR distinction on Windows --- .../session-persistence-jsonl/src/index.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index ac1c07860e..2f33664139 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import z from 'schemastery' -import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { @@ -389,7 +389,27 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } catch (error) { // Only ENOENT means absent. A permission/I/O error must surface rather // than letting load or collision checks proceed under false absence. - if (isENOENT(error)) return false + // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify + // the immediate parent so a blocked cwd bucket remains a storage fault. + if (isENOENT(error)) { + await this.assertLogParentAllowsAbsence(path) + return false + } + throw error + } + } + + private async assertLogParentAllowsAbsence(path: string): Promise { + try { + const parent = dirname(path) + const info = await fsStat(parent) + if (info.isDirectory()) return + const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException + error.code = 'ENOTDIR' + error.path = parent + throw error + } catch (error) { + if (isENOENT(error)) return throw error } } From 86c09f6ca9d4bcec8226bd7a62febb1580bb96f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:50:37 +0800 Subject: [PATCH 08/46] test(jsonl): mark native Windows ENOTDIR coverage --- docs/config-catalog.md | 2 +- .../session-persistence/session-persistence-jsonl/src/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3e3d8141fa..ba273888d1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -634,7 +634,7 @@ export interface Config { } ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:25`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 2f33664139..39aa493a0b 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -399,6 +399,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */ private async assertLogParentAllowsAbsence(path: string): Promise { try { const parent = dirname(path) @@ -413,6 +414,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi throw error } } + /* v8 ignore stop */ } export default SessionPersistenceJsonl From beb13c3808c643a93e3a4b0d6fc698d050638321 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:53:13 +0800 Subject: [PATCH 09/46] test(acp): pin Windows-native snapshot paths --- examples/acp-agent/tests/acp.snapshot.ts | 10 +- .../stdout.golden.windows.jsonl | 132 ++++++++++++++++++ packages/support/acp-snapshot/README.md | 10 +- packages/support/acp-snapshot/src/harness.ts | 11 +- packages/support/acp-snapshot/src/index.ts | 2 + .../support/acp-snapshot/src/normalize.ts | 66 +++++++-- packages/support/acp-snapshot/src/suite.ts | 49 ++++++- .../acp-snapshot/tests/harness.spec.ts | 7 +- .../acp-snapshot/tests/normalize.spec.ts | 77 ++++++++++ .../support/acp-snapshot/tests/suite.spec.ts | 26 ++++ 10 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 23eca9dbc0..e68724cbfe 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -57,7 +57,15 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, - { name: 'workspace-edit', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'fs', configPath: FS_CONFIG }, + { + name: 'workspace-edit', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + pinsNativeWindowsStdout: true, + headerClass: 'fs', + configPath: FS_CONFIG, + }, { name: 'fs-read', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-write', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-edit', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl new file mode 100644 index 0000000000..5f8762adcd --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl @@ -0,0 +1,132 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" greeting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Append"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORLD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" confirm"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}\\greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" append"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WOR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" confirm"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","title":"printf '\\nWORLD' >> greeting.txt","kind":"execute","status":"in_progress","rawInput":"printf '\\nWORLD' >> greeting.txt","content":[{"type":"content","content":{"type":"text","text":"Append newline and WORLD to greeting.txt"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\n(no output)\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Good"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","title":"cat greeting.txt","kind":"execute","status":"in_progress","rawInput":"cat greeting.txt","content":[{"type":"content","content":{"type":"text","text":"Read greeting.txt to confirm"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nhello\n\nWORLD\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORLD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 222d572043..9969eadb46 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,9 +4,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Three layers, importable separately: -- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. +- **`runScenario` (harness)** — boots the real agent bin in the selected example mode: source under tsx or built `lib` under plain Node. It drives ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden and purity check, and harvests every persisted session JSONL (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is a temp directory outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic. +- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators → `/` for shared goldens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario shared golden + re-persisted-log compares, optional Windows-native stdout sidecars, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: @@ -37,6 +37,8 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +Every scenario compares `stdout.golden.jsonl` with cwd-rooted separators canonicalized to `/`. A scenario may set `pinsNativeWindowsStdout` to add a Windows-only comparison against the complete `stdout.golden.windows.jsonl`; the shared golden still runs first on Windows, and the fixture guard requires the sidecar exactly when declared. + Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). `suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. @@ -48,4 +50,4 @@ None, as this test-only harness records, normalizes, and compares ACP transcript ## Known Limitations and Deferred Work - **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path. -- **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier. +- **Built mode requires current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index e2072b6b17..cf8d7afc27 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -150,6 +150,15 @@ export interface RunOptions { configPath?: string } +/** + * Return a fixed-length spill root across POSIX and Windows after Windows adds its drive prefix. + * @param platform - the host platform, injectable for unit coverage. + * @returns the root-relative snapshot spill directory. + */ +export function snapshotSpillRoot(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? '/t/dsh-acp-snapshot-spill' : '/tmp/dsh-acp-snapshot-spill' +} + /** * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the * child and its temp dirs; always tears them down. Returns the captured stdout @@ -164,7 +173,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn goldens. - const spillRoot = '/tmp/dsh-acp-snapshot-spill' + const spillRoot = snapshotSpillRoot() // Everything past the temp-dir creation runs under a try/finally that always // removes both dirs — so a failure in workspace seeding, spawn, or any step // never leaks them (the "e2e tests own their resources" rule). diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index bdf8cccaf7..46eac5568b 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -21,7 +21,9 @@ export { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, + type CwdPathMode, type NormalizeContext, + type NormalizeOptions, } from './normalize.ts' export { defineAcpSnapshotSuite, diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 48761864f0..76bd40d610 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -12,19 +12,33 @@ const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' const MESSAGE_PREFIX = '{{messagePrefix}}' +/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */ +const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g +const PATH_TAG_RE = /()([^<]*)(<\/path>)/g +const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g + /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi const LOCAL_SPILL_PATH_RE = new RegExp( - String.raw`\{\{cwd\}\}/\.spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`\{\{cwd\}\}[\\/]\.spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, 'g', ) const SNAPSHOT_SPILL_PATH_RE = new RegExp( - String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/]dsh-acp-snapshot-spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, 'g', ) +/** Convert separators only inside generated path-bearing text markers. */ +function canonicalizeEmbeddedPaths(value: string): string { + return value + .replace(PATH_TAG_RE, (_match, open: string, path: string, close: string) => + `${open}${path.replaceAll('\\', '/')}${close}`) + .replace(ADDITIONAL_INSTRUCTIONS_PATH_RE, (_match, prefix: string, path: string) => + `${prefix}${path.replaceAll('\\', '/')}`) +} + /** Inputs the normalizers need to recognize a run's volatile values. */ export interface NormalizeContext { /** The session id(s) the run issued — replaced with `{{sessionId}}`. */ @@ -33,13 +47,28 @@ export interface NormalizeContext { cwd: string } +/** How cwd-rooted path separators are represented after the cwd is tokenized. */ +export type CwdPathMode = 'canonical' | 'native' + +/** Optional controls shared by stdout and session-log normalization. */ +export interface NormalizeOptions { + /** Use `/` for shared goldens, or preserve captured separators for a platform-specific golden. */ + cwdPathMode?: CwdPathMode +} + /** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */ -function scrubString(value: string, ctx: NormalizeContext): string { +function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string { let out = value // cwd first (longest, most specific), then explicit session ids, then any // residual UUID (covers ids that appear in places we didn't enumerate). out = out.split(ctx.cwd).join(CWD) out = out.split(`/private${CWD}`).join(CWD) + if (cwdPathMode === 'canonical') { + // Restrict separator conversion to paths rooted at the cwd token. A global + // backslash rewrite would corrupt regexes, commands, and model-authored text. + out = out.replace(CWD_ROOTED_PATH_RE, path => path.replaceAll('\\', '/')) + out = canonicalizeEmbeddedPaths(out) + } out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) @@ -48,12 +77,15 @@ function scrubString(value: string, ctx: NormalizeContext): string { } /** Recursively scrub a parsed JSON value (strings replaced; structure kept). */ -function scrubValue(value: unknown, ctx: NormalizeContext): unknown { - if (typeof value === 'string') return scrubString(value, ctx) - if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx)) +function scrubValue(value: unknown, ctx: NormalizeContext, cwdPathMode: CwdPathMode, key?: string): unknown { + if (typeof value === 'string') { + const scrubbed = scrubString(value, ctx, cwdPathMode) + return cwdPathMode === 'canonical' && key === 'path' ? scrubbed.replaceAll('\\', '/') : scrubbed + } + if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx, cwdPathMode)) if (value !== null && typeof value === 'object') { const out: Record = {} - for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx) + for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx, cwdPathMode, k) return out } return value @@ -67,9 +99,15 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown { * * @param rawStdout The captured stdout bytes, decoded utf8. * @param ctx The run's volatile values to scrub. + * @param options Separator output controls; shared canonical paths are the default. * @returns The normalized NDJSON transcript, one frame per line. */ -export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string { +export function normalizeStdout( + rawStdout: string, + ctx: NormalizeContext, + options: NormalizeOptions = {}, +): string { + const cwdPathMode = options.cwdPathMode ?? 'canonical' const lines = rawStdout.split('\n').filter(line => line.trim().length > 0) // Map each distinct JSON-RPC id (request/response correlate by id) to a stable // sequence number, in first-seen order, so id churn doesn't perturb the golden. @@ -85,7 +123,7 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin if ('id' in frame && frame.id !== undefined && frame.id !== null) { frame.id = stableId(frame.id) } - return scrubValue(frame, ctx) as Record + return scrubValue(frame, ctx, cwdPathMode) as Record }) return frames.map(f => JSON.stringify(f)).join('\n') + '\n' } @@ -99,9 +137,15 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin * * @param rawLog The raw session `.jsonl` content. * @param ctx The run's volatile values to scrub. + * @param options Separator output controls; shared canonical paths are the default. * @returns The normalized JSONL log, one record per line. */ -export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): string { +export function normalizeSessionLog( + rawLog: string, + ctx: NormalizeContext, + options: NormalizeOptions = {}, +): string { + const cwdPathMode = options.cwdPathMode ?? 'canonical' const lines = rawLog.split('\n').filter(line => line.trim().length > 0) const records = lines.map((line) => { const record = JSON.parse(line) as Record @@ -119,7 +163,7 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri if ('durationMs' in data) data.durationMs = 0 } } - return scrubValue(record, ctx) as Record + return scrubValue(record, ctx, cwdPathMode) as Record }) return records.map(r => JSON.stringify(r)).join('\n') + '\n' } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 322439bb01..7fdc70dc67 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -21,6 +21,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts' import { + type CwdPathMode, type NormalizeContext, normalizeSessionLog, normalizeStdout, @@ -35,6 +36,9 @@ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.golden.md' /** The structured tool-schema snapshot beside each header-pinning fixture. */ const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.golden.json' +/** The optional full Windows-native stdout transcript. */ +const WINDOWS_STDOUT_SNAPSHOT = 'stdout.golden.windows.jsonl' + /** Stable session-log token standing in for the sidecar's initial schemas. */ const TOOLS_TOKEN = '{{tools}}' @@ -108,6 +112,35 @@ export interface Scenario { * {@link headerClass}. */ configPath?: string + /** + * Whether Windows additionally compares stdout with native separators against + * `stdout.golden.windows.jsonl`. The shared canonical stdout golden is still + * compared on every platform, and the fixture guard requires this sidecar + * exactly when the option is set. + */ + pinsNativeWindowsStdout?: boolean +} + +/** One stdout golden selected for a platform run. */ +interface StdoutGoldenVariant { + file: string + cwdPathMode: CwdPathMode +} + +/** + * Select the shared stdout golden plus any platform-native assertion declared by a scenario. + * + * @param scenario The scenario whose stdout contract is being selected. + * @param platform The running Node platform, injectable for unit coverage. + * @returns The ordered golden variants: shared canonical first, then optional Windows native. + */ +export function stdoutGoldenVariants( + scenario: Scenario, + platform: NodeJS.Platform = process.platform, +): StdoutGoldenVariant[] { + const canonical: StdoutGoldenVariant = { file: 'stdout.golden.jsonl', cwdPathMode: 'canonical' } + if (platform !== 'win32' || scenario.pinsNativeWindowsStdout !== true) return [canonical] + return [canonical, { file: WINDOWS_STDOUT_SNAPSHOT, cwdPathMode: 'native' }] } /** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */ @@ -530,11 +563,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } - const stdout = normalizeStdout(result.rawStdout, ctx) - if (REFRESHING) { - await writeFile(join(dir, 'stdout.golden.jsonl'), stdout) + for (const golden of stdoutGoldenVariants(scenario)) { + const stdout = normalizeStdout(result.rawStdout, ctx, { cwdPathMode: golden.cwdPathMode }) + if (REFRESHING) { + await writeFile(join(dir, golden.file), stdout) + } + await expect(stdout, `${golden.file} mismatch`).toMatchFileSnapshot(join(dir, golden.file)) } - await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl')) // A model turn always produces a log worth comparing; a hook scenario can // produce one without a model turn (a `rejected` turn carrying `hook/*`). @@ -621,10 +656,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { it('every registered scenario has its required fixture files', () => { // Every scenario has an input script and an stdout golden. - for (const { name, overridden, childSessions, pinsHeader } of scenarios) { + for (const { name, overridden, childSessions, pinsHeader, pinsNativeWindowsStdout } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) + expect( + existsSync(join(dir, WINDOWS_STDOUT_SNAPSHOT)), + `${name}/${WINDOWS_STDOUT_SNAPSHOT} presence must match \`pinsNativeWindowsStdout\``, + ).toBe(pinsNativeWindowsStdout === true) expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``) .toBe(overridden === true) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index d141fb1f09..35fcb21d09 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' -import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' +import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts' /** * Unit tests for the subprocess harness, driven through the REAL spawn path @@ -39,6 +39,11 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] +it('keeps the resolved snapshot spill root length stable across platforms', () => { + expect(snapshotSpillRoot('linux')).toBe('/tmp/dsh-acp-snapshot-spill') + expect(snapshotSpillRoot('win32')).toBe('/t/dsh-acp-snapshot-spill') +}) + describe('runScenario', () => { it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' }) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index 2beaba5114..fed086fec7 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -44,6 +44,56 @@ describe('normalizeStdout', () => { expect(out).not.toContain(ctx.sessionIds[0] as string) }) + it('canonicalizes only cwd-rooted path separators', () => { + const windowsCtx: NormalizeContext = { + sessionIds: [], + cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`, + } + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + path: `${windowsCtx.cwd}\\nested\\proof.txt`, + regex: String.raw`\d+\w+`, + command: String.raw`printf "\\n"`, + }, + }) + const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as { + params: { path: string; regex: string; command: string } + } + expect(frame.params).toEqual({ + path: '{{cwd}}/nested/proof.txt', + regex: String.raw`\d+\w+`, + command: String.raw`printf "\\n"`, + }) + }) + + it('canonicalizes generated relative path fields and text markers without rewriting other text', () => { + const raw = JSON.stringify({ + path: String.raw`nested\AGENTS.md`, + content: String.raw`.\nested\task.txt +Additional instructions from: nested\AGENTS.md`, + regex: String.raw`\d+\w+`, + }) + const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as { + path: string + content: string + regex: string + } + expect(frame).toEqual({ + path: 'nested/AGENTS.md', + content: './nested/task.txt\nAdditional instructions from: nested/AGENTS.md', + regex: String.raw`\d+\w+`, + }) + }) + + it('can preserve native cwd-rooted separators for a platform golden', () => { + const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` } + const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` }) + const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string } + expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`) + }) + it('scrubs a stray UUID not in the known list', () => { const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } }) expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}') @@ -139,6 +189,33 @@ describe('normalizeSessionLog', () => { expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill') }) + it('scrubs fixed snapshot spill paths with Windows drive and separators', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snapshot-spill\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`, + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('C:\\t\\dsh-acp-snapshot-spill') + }) + + it('shares cwd-rooted path handling with stdout normalization', () => { + const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` } + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` }, + }) + expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx)) + .toContain('{{cwd}}/nested/proof.txt') + expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' })) + .toContain(String.raw`{{cwd}}\\nested\\proof.txt`) + }) + it('scrubs the session id in the header', () => { const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx) expect(out).toContain('{{sessionId}}') diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index c2007321de..24812029dc 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -18,6 +18,7 @@ import { refreshFixtureReplacements, restorePinnedToolSchemas, stabilizeRefreshLog, + stdoutGoldenVariants, unknownToolCallIds, } from '../src/suite.ts' @@ -189,6 +190,31 @@ describe('childFixturePaths', () => { }) }) +describe('stdoutGoldenVariants', () => { + const scenario: Scenario = { + name: 'windows-native', + hasModelTurn: true, + recorded: true, + pinsNativeWindowsStdout: true, + } + + it('adds the native sidecar after the shared golden on Windows', () => { + expect(stdoutGoldenVariants(scenario, 'win32')).toEqual([ + { file: 'stdout.golden.jsonl', cwdPathMode: 'canonical' }, + { file: 'stdout.golden.windows.jsonl', cwdPathMode: 'native' }, + ]) + }) + + it('keeps only the shared golden on other platforms or without the declaration', () => { + expect(stdoutGoldenVariants(scenario, 'linux')).toEqual([ + { file: 'stdout.golden.jsonl', cwdPathMode: 'canonical' }, + ]) + expect(stdoutGoldenVariants({ ...scenario, pinsNativeWindowsStdout: false }, 'win32')).toEqual([ + { file: 'stdout.golden.jsonl', cwdPathMode: 'canonical' }, + ]) + }) +}) + describe('fixtureContext', () => { it('reads the fixture header id and cwd', () => { const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n') From b5fa2cb2b8c3a320f773e21695daa7a8b222cc87 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:30:16 +0800 Subject: [PATCH 10/46] test(windows): skip unsupported SDK test surfaces --- .../sdk/create-sdk/tests/create.snapshot.ts | 2 +- vitest.config.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/sdk/create-sdk/tests/create.snapshot.ts b/packages/sdk/create-sdk/tests/create.snapshot.ts index a5ea46db53..4733653a0d 100644 --- a/packages/sdk/create-sdk/tests/create.snapshot.ts +++ b/packages/sdk/create-sdk/tests/create.snapshot.ts @@ -71,7 +71,7 @@ class RecordingPort implements PromptPort { } } -describe('create-sdk terminal contract', () => { +describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => { it('renders package-manager-specific setup commands', () => { const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0')) expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n') diff --git a/vitest.config.ts b/vitest.config.ts index c0946e2e10..1001b7daa3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,16 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +const windowsUnsupportedPackages = process.platform === 'win32' + ? [ + 'packages/bash/*', + 'packages/hooks/*', + 'packages/sandbox/sandbox-local', + 'packages/sdk/create-sdk', + 'packages/sdk/helper', + ] + : [] + export default defineConfig({ // Native path resolution reads each package's nearest tsconfig, but only the root defines // workspace paths. Keep this plugin pinned to the root map so unbuilt bare package imports resolve @@ -8,6 +18,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], + exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no @@ -16,7 +27,12 @@ export default defineConfig({ include: ['packages/*/*/src/**/*.ts'], // Types-only files have no runtime coverage. Importing self-executing bins/workers would boot // them inside the unit process, so real subprocess/Worker tests cover their thin entry glue. - exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts'], + exclude: [ + 'packages/*/*/src/types.ts', + 'packages/*/*/src/bin.ts', + 'packages/*/*/src/worker.ts', + ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see the quality-gates RFC From 228f6e3867ce4cdbbd2d4edb85ef132c91e5634c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:44:19 +0800 Subject: [PATCH 11/46] test(windows): skip POSIX-only assertions --- .../workspace-context/tests/workspace-context.spec.ts | 2 +- packages/spill/spill-local/tests/spill-local.spec.ts | 2 +- packages/subagent/subagent-acp/tests/subagent-acp.spec.ts | 2 +- .../subagent-subprocess/tests/subagent-subprocess.spec.ts | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index b3a9947221..5f04b3a485 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2403,7 +2403,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('skips unreadable nested instruction files without attaching empty context', async () => { + it.skipIf(process.platform === 'win32')('skips unreadable nested instruction files without attaching empty context', async () => { const root = await tempRepo() const home = await tempRepo() try { diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index d73fca9fe3..b4abc6e3d6 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -84,7 +84,7 @@ describe('saveTextFile', () => { expect(saved.path.includes('/..')).toBe(false) }) - it('creates the session dir with owner-only permissions', async () => { + it.skipIf(process.platform === 'win32')('creates the session dir with owner-only permissions', async () => { const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' }) // 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold). expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index bfc8476bae..e3fd3eda48 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -263,7 +263,7 @@ describe('dsh-subagent-acp', () => { } }) - it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { + it.skipIf(process.platform === 'win32')('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { // A child that keeps its loop alive past stdin EOF (so the graceful window // times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier // — dispose returns there, never reaching the SIGKILL tier. The child touches diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index bdc0260c73..8ed3891578 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -258,8 +258,9 @@ describe('createIsolatedConfigDir', () => { expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true) const st = await stat(dir.path) expect(st.isDirectory()).toBe(true) - // Private (0700) per the defensive-patterns temp-dir rule. - expect(st.mode & 0o777).toBe(0o700) + // Windows reports synthetic POSIX mode bits; privacy comes from the + // inherited directory ACL rather than chmod-compatible mode bits. + if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700) } finally { await dir.remove() } From 7e620db8ba470cc48b44c8a1887208b03206bd3a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:08:59 +0800 Subject: [PATCH 12/46] test(windows): use host path semantics --- .../tests/workspace-context.spec.ts | 46 +++++++++---------- .../fs/tool-fs-search/tests/tools.spec.ts | 7 +-- .../spill-local/tests/spill-local.spec.ts | 7 +-- packages/util/paths/tests/paths.spec.ts | 4 +- 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 5f04b3a485..9de003ae8d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1,5 +1,5 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' @@ -61,7 +61,7 @@ class RecordingFileSystem extends FileSystem { override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { if (opts?.signal !== undefined) this.signals.push(opts.signal) opts?.signal?.throwIfAborted() - const absolute = join(opts?.cwd ?? '/', path) + const absolute = resolve(opts?.cwd ?? '/', path) return { targetKey: FsTargetKey(absolute), displayPath: absolute } } @@ -277,8 +277,8 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual([ '$DSH_HOME/AGENTS.md', 'AGENTS.md', - 'packages/CLAUDE.md', - 'packages/app/AGENTS.md', + join('packages', 'CLAUDE.md'), + join('packages', 'app', 'AGENTS.md'), ]) expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md')) } finally { @@ -336,7 +336,7 @@ describe('workspace context instruction discovery', () => { } }) - it('skips a file that becomes unreadable after discovery without failing the request', async () => { + it.skipIf(process.platform === 'win32')('skips a file that becomes unreadable after discovery without failing the request', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -932,7 +932,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('omitted AGENTS.md') - expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule') + expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1398,7 +1398,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule') - expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule') + expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`) await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) @@ -1700,7 +1700,7 @@ describe('dynamic nested workspace context injection', () => { changes: [{ action: 'set', scope: 'pkg', - path: 'pkg/AGENTS.md', + path: join('pkg', 'AGENTS.md'), }], }) const meta = workspaceContextOf(result)?.meta @@ -1714,7 +1714,7 @@ describe('dynamic nested workspace context injection', () => { const text = blocksText(workspaceContextOf(result)?.content) expect(text).toBe([ '', - 'Additional instructions from: pkg/AGENTS.md', + `Additional instructions from: ${join('pkg', 'AGENTS.md')}`, '', 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', '', @@ -1752,7 +1752,7 @@ describe('dynamic nested workspace context injection', () => { }) const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`) expect(text).toContain('local package rule') expect(text).not.toContain('native package rule') } finally { @@ -1922,11 +1922,11 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(changed)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }], + changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ '', - 'Updated instructions from: pkg/AGENTS.md', + `Updated instructions from: ${join('pkg', 'AGENTS.md')}`, '', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', @@ -1966,11 +1966,11 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(changed)?.meta).toMatchObject({ changes: [{ - action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md', + action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'), }], }) - expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md') - expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.') + expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`) + expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`) expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule') expect(unchanged.additionalContexts).toBeUndefined() } finally { @@ -2002,11 +2002,11 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(removed)?.meta).toEqual({ kind: 'workspace-instructions', version: 1, - changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }], + changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ '', - 'Instructions removed: pkg/AGENTS.md', + `Instructions removed: ${join('pkg', 'AGENTS.md')}`, '', 'The previously loaded instructions from this file no longer apply.', '', @@ -2044,9 +2044,9 @@ describe('dynamic nested workspace context injection', () => { }) expect(workspaceContextOf(restored)?.meta).toMatchObject({ - changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], + changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule') } finally { await rm(root, { recursive: true, force: true }) @@ -2146,7 +2146,7 @@ describe('dynamic nested workspace context injection', () => { const update = resumed.session.events.findLast(event => event.type === 'context/message') expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ - changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }], + changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], }) expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { @@ -2267,8 +2267,8 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(workspaceContextOf(first)?.content) - expect(firstText).toContain('omitted pkg/AGENTS.md') - expect(firstText).not.toContain('## pkg/AGENTS.md') + expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`) + expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`) expect(firstText).toContain('subtree rule') expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule') } finally { @@ -2462,7 +2462,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], + changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context') diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5363344f07..add5b932f9 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { join } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -416,7 +417,7 @@ describe('glob results', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) - expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts') + expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`) }) it('validates arguments (blank pattern, blank path)', async () => { @@ -498,7 +499,7 @@ describe('grep results', () => { const { ctx, bash } = await setup() bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') }) - expect(text(result)).toContain('deep/a.ts\nLine 2: hit') + expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`) }) it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => { @@ -608,7 +609,7 @@ describe('presentation', () => { describe('helpers', () => { it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => { - expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts') + expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe(join('a', 'b.ts')) expect(toWorkdirRelative('/w', '/w')).toBe('.') expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts') expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts') diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index b4abc6e3d6..46fa0b66b2 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { Context } from 'cordis' import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, isAbsolute, join } from 'node:path' +import { basename, dirname, isAbsolute, join, normalize } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SaveTextSpill } from '@deepseek-ai/dsh-spill' @@ -63,7 +63,8 @@ describe('sessionDir', () => { it('is a stable per-session hash under the root', () => { const dir = sessionDir('/spill', 'sess-1') expect(dir).toBe(sessionDir('/spill', 'sess-1')) - expect(dir).toMatch(/\/spill\/session-[0-9a-f]{12}$/) + expect(dirname(dir)).toBe(normalize('/spill')) + expect(basename(dir)).toMatch(/^session-[0-9a-f]{12}$/) expect(sessionDir('/spill', 'sess-2')).not.toBe(dir) }) }) @@ -74,7 +75,7 @@ describe('saveTextFile', () => { expect(readFileSync(saved.path, 'utf8')).toBe('héllo') expect(saved.bytes).toBe(Buffer.byteLength('héllo', 'utf8')) expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1')) - expect(saved.path).toMatch(/\/[0-9a-f]{12}-r\.txt$/) + expect(basename(saved.path)).toMatch(/^[0-9a-f]{12}-r\.txt$/) }) it('sanitizes a traversal-shaped suggested name into one segment', async () => { diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 97e91a556e..4ba4fab155 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, @@ -28,7 +28,7 @@ describe('dsh path helpers', () => { const envHome = join(homedir(), 'env-dsh') expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome) - expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe('/tmp/explicit-dsh') + expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe(resolve('/tmp/explicit-dsh')) expect(resolveDshHome(undefined, {})).toBe(defaultDshHome()) }) }) From 3a82b3edd7d52107578c1add212ade384bebfe55 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:28:46 +0800 Subject: [PATCH 13/46] ci(windows): observe runtime coverage and snapshots docs: record cross-platform gate boundaries --- .github/AGENTS.md | 3 +++ .github/workflows/ci.yml | 24 +++++++++++++++++++----- scripts/AGENTS.md | 3 +++ 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 .github/AGENTS.md create mode 100644 scripts/AGENTS.md diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000000..00e3ed8f87 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md — CI gates + +Run Windows gates from native `pwsh`, invoke pnpm shell-free, and normalize repo-relative glob paths to `/` at ingestion. Keep platform fixes at each gate boundary; do not add a shared platform layer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac25b07cb..de9a03288b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,11 +182,9 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build - # Observational, non-blocking Windows static, lint, and artifact lanes. Coverage - # and snapshot stay Linux-only until their platform-specific runtime failures - # have dedicated support. Run the gates from native PowerShell: an MSYS parent - # would change the environment being measured. This job intentionally stays - # out of all-checks-passed.needs. + # Observational, non-blocking Windows mirror of the Linux gate lanes. Run the + # gates from native PowerShell: an MSYS parent would change the environment + # being measured. This job intentionally stays out of all-checks-passed.needs. windows-gates: continue-on-error: true runs-on: windows-2025 @@ -194,6 +192,7 @@ jobs: env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false @@ -203,16 +202,31 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' publint_concurrency: '8' + coverage_max_workers: '' eslint_cache: '' - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' publint_concurrency: '8' + coverage_max_workers: '' eslint_cache: '1' + - lane: coverage + command: pnpm run check:ci:coverage + gate_concurrency: '1' + publint_concurrency: '8' + coverage_max_workers: '4' + eslint_cache: '' + - lane: snapshot + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + publint_concurrency: '8' + coverage_max_workers: '' + eslint_cache: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' + coverage_max_workers: '' eslint_cache: '' steps: - uses: actions/checkout@v6 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000000..2d1ebafc27 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md — Repository scripts + +Gate-related scripts follow the [CI gate rules](../.github/AGENTS.md). From 1d6acc331538aa7d5144257fcfd6690acd69eb1f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:23:52 +0800 Subject: [PATCH 14/46] test(loader-smoke): use native home paths --- packages/support/loader-smoke/tests/loader-smoke.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index b99d810188..755197b134 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' @@ -33,8 +34,8 @@ describe('runLoaderSmoke', () => { marker: 'present', input: 'one\ntwo\n', }) - expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`) - expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`) + expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh'))) + expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents'))) expect(result.stderr).toContain('fixture stderr') expect(existsSync(output.cwd)).toBe(false) }, LOADER_SMOKE_TEST_TIMEOUT_MS) From 588f4d948d8ac1c023366dbdc337546cb3ca65d3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:10:18 +0800 Subject: [PATCH 15/46] test(windows): mark platform-only coverage branches --- packages/context/workspace-context/src/files.ts | 1 + packages/context/workspace-context/src/state.ts | 1 + packages/fs/fs-local/src/fsio.ts | 1 + packages/sandbox/sandbox/src/index.ts | 1 + .../session-persistence-jsonl/src/index.ts | 5 +++++ packages/skill/skill-local/src/index.ts | 1 + 6 files changed, 10 insertions(+) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index feb6304b4c..770024c569 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -459,6 +459,7 @@ export async function readScopeInstruction( signal?: AbortSignal, ): Promise { const content = await readBounded(file, maxSourceBytes, fileSystem, signal) + /* v8 ignore next -- Windows cannot reproduce a post-probe unreadable file with POSIX mode bits. */ if (content === undefined) return undefined return { absolutePath: file.absolutePath, diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 98b8fcc066..ab18f442f4 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -437,6 +437,7 @@ export async function reconcileInstructionContext( ) continue const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) + /* v8 ignore next -- Windows cannot make the probed file unreadable through POSIX mode bits. */ if (file === undefined) continue const currentDigest = instructionContentSha1(file.content) const nextVersion: InstructionVersionState = { diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 4603611ce4..9592ba0349 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -175,6 +175,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { await mkdir(this.root, { recursive: true, mode: 0o700 }) await this.syncDirPosix(dirname(this.root)) @@ -213,6 +214,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */ } } + /* v8 ignore stop */ /* v8 ignore start -- native Windows coverage exercises this integration path */ private async materializeWin32(dir: string, finalPath: string, id: SessionId, content: string): Promise { @@ -254,6 +256,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */ + /* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */ private async syncDirPosix(dir: string): Promise { const handle = await open(dir, 'r') try { @@ -262,6 +265,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await handle.close() } } + /* v8 ignore stop */ /** * Append and fsync event lines. On a partial write or sync failure, restore the @@ -395,6 +399,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await this.assertLogParentAllowsAbsence(path) return false } + /* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */ throw error } } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index ee109fbb16..29f5c622b9 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -317,6 +317,7 @@ async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; const info = await stat(fullPath) if (info.isDirectory()) return 'directory' if (info.isFile()) return 'file' + /* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */ return undefined } catch (error) { ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`) From b426c2f19c06a6132e2d21992af8f2e2f5679506 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:13:53 +0800 Subject: [PATCH 16/46] docs(api): refresh sandbox source link --- website/zh-CN/api/harness/sandbox.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/zh-CN/api/harness/sandbox.md b/website/zh-CN/api/harness/sandbox.md index bc5b1d38a5..51980d36d7 100644 --- a/website/zh-CN/api/harness/sandbox.md +++ b/website/zh-CN/api/harness/sandbox.md @@ -21,4 +21,4 @@ Wrap `argv` so it executes confined under `policy` on this host; the caller spaw **Returns** the argv to spawn instead, plus the enforcement completeness the selected backend achieves for it. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/sandbox/sandbox/src/index.ts#L127) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/sandbox/sandbox/src/index.ts#L128) From f62cc439e9bbc7e26e674e3a82b53e4dbb05469c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:03:44 +0800 Subject: [PATCH 17/46] docs(windows): clarify portability rules --- .github/AGENTS.md | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- scripts/AGENTS.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 00e3ed8f87..5f03c8617d 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ -# AGENTS.md — CI gates +# AGENTS.md — GitHub Actions -Run Windows gates from native `pwsh`, invoke pnpm shell-free, and normalize repo-relative glob paths to `/` at ingestion. Keep platform fixes at each gate boundary; do not add a shared platform layer. +Run Windows jobs under native `pwsh`. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 9969eadb46..57f7245d7c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -37,7 +37,7 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. -Every scenario compares `stdout.golden.jsonl` with cwd-rooted separators canonicalized to `/`. A scenario may set `pinsNativeWindowsStdout` to add a Windows-only comparison against the complete `stdout.golden.windows.jsonl`; the shared golden still runs first on Windows, and the fixture guard requires the sidecar exactly when declared. +Every scenario compares `stdout.golden.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.golden.windows.jsonl` after the shared golden and requires that sidecar exactly when enabled. Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 2d1ebafc27..68ea79ea7b 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate-related scripts follow the [CI gate rules](../.github/AGENTS.md). +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer. From 7d28b611b2b72bec88c7e722add48d443c42f7e3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:55:37 +0800 Subject: [PATCH 18/46] test(windows): align platform coverage ignores --- packages/fs/fs-local/src/fsio.ts | 5 ++++- packages/sandbox/sandbox/src/index.ts | 2 +- .../session-persistence-jsonl/src/index.ts | 1 + packages/skill/skill-local/src/index.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 9592ba0349..05d957105a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -133,6 +133,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise Date: Sat, 18 Jul 2026 16:25:16 +0800 Subject: [PATCH 19/46] test(windows): replace POSIX-only filesystem fixtures --- .../context/workspace-context/src/files.ts | 1 - .../context/workspace-context/src/state.ts | 1 - .../tests/workspace-context.spec.ts | 43 ++++++++++++------- .../spill-local/tests/spill-local.spec.ts | 13 ++++-- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 770024c569..feb6304b4c 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -459,7 +459,6 @@ export async function readScopeInstruction( signal?: AbortSignal, ): Promise { const content = await readBounded(file, maxSourceBytes, fileSystem, signal) - /* v8 ignore next -- Windows cannot reproduce a post-probe unreadable file with POSIX mode bits. */ if (content === undefined) return undefined return { absolutePath: file.absolutePath, diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index ab18f442f4..98b8fcc066 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -437,7 +437,6 @@ export async function reconcileInstructionContext( ) continue const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) - /* v8 ignore next -- Windows cannot make the probed file unreadable through POSIX mode bits. */ if (file === undefined) continue const currentDigest = instructionContentSha1(file.content) const nextVersion: InstructionVersionState = { diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 9de003ae8d..75238d6fb6 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' @@ -53,6 +53,7 @@ class RecordingFileSystem extends FileSystem { entries = new Map() lstatTypes = new Map() throwOnStat = new Set() + throwOnRead = new Set() omitSizes = new Set() readTargets: string[] = [] readTextTargets: string[] = [] @@ -105,6 +106,7 @@ class RecordingFileSystem extends FileSystem { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() this.readTargets.push(target.targetKey) + if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`) const content = this.entries.get(target.targetKey)?.content ?? '' return (async function* () { const midpoint = Math.ceil(content.length / 2) @@ -336,22 +338,25 @@ describe('workspace context instruction discovery', () => { } }) - it.skipIf(process.platform === 'win32')('skips a file that becomes unreadable after discovery without failing the request', async () => { + it('skips a provider file whose read fails after a successful metadata probe', async () => { const root = await tempRepo() const home = await tempRepo() + const ctx = new Context() try { const cwd = join(root, 'pkg') - await mkdir(join(root, '.git'), { recursive: true }) - await mkdir(cwd, { recursive: true }) const leaf = join(cwd, 'AGENTS.md') - await write(leaf, 'secret-ish rule') - await chmod(leaf, 0) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' }) + fs.throwOnRead.add(leaf) - const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) + const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs) expect(loaded).toBeUndefined() - await chmod(leaf, 0o600) + expect(fs.readTargets).toEqual([leaf]) } finally { + await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } @@ -2403,17 +2408,22 @@ describe('dynamic nested workspace context injection', () => { } }) - it.skipIf(process.platform === 'win32')('skips unreadable nested instruction files without attaching empty context', async () => { + it('skips unreadable nested instruction files without attaching empty context', async () => { const root = await tempRepo() const home = await tempRepo() + const ctx = new Context() try { - await mkdir(join(root, '.git'), { recursive: true }) const nested = join(root, 'pkg/AGENTS.md') - await write(nested, 'nested package rule') - await write(join(root, 'pkg/deep/file.txt'), 'hello') - await chmod(nested, 0) - const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(nested, { type: 'file', content: 'nested package rule' }) + fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) + fs.throwOnRead.add(nested) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ callId: CallId('read-with-unreadable-nested-instruction'), @@ -2424,8 +2434,9 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(result.additionalContexts).toBeUndefined() - await chmod(nested, 0o600) + expect(fs.readTargets).toContain(nested) } finally { + await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 46fa0b66b2..3c6f9ac82d 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -85,11 +85,16 @@ describe('saveTextFile', () => { expect(saved.path.includes('/..')).toBe(false) }) - it.skipIf(process.platform === 'win32')('creates the session dir with owner-only permissions', async () => { + it('creates the session directory and file with owner-only POSIX permissions', async () => { const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' }) - // 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold). - expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700) - expect(statSync(saved.path).mode & 0o600).toBe(0o600) + const directory = statSync(dirname(saved.path)) + const file = statSync(saved.path) + expect(directory.isDirectory()).toBe(true) + expect(file.isFile()).toBe(true) + if (process.platform !== 'win32') { + expect(directory.mode & 0o777).toBe(0o700) + expect(file.mode & 0o777).toBe(0o600) + } }) it('gives distinct paths to two saves of the same name', async () => { From 64f64724e5caaca1c59db5bd953c4a8e1214c538 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:25:51 +0800 Subject: [PATCH 20/46] fix(subprocess): honor Windows termination semantics --- docs/config-catalog.md | 2 +- packages/subagent/subagent-acp/README.md | 6 ++--- packages/subagent/subagent-acp/src/index.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 14 +++++------ .../subagent-acp/tests/subagent-acp.spec.ts | 16 ++++--------- .../subagent/subagent-subprocess/README.md | 10 ++++---- .../subagent/subagent-subprocess/src/index.ts | 23 +++++++++++++------ .../tests/subagent-subprocess.spec.ts | 13 ++++++++--- 8 files changed, 48 insertions(+), 38 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ba273888d1..b31721ef99 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -853,7 +853,7 @@ export interface Config { * before the parent escalates to a signal. */ disposeEofGraceMs?: number - /** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */ + /** POSIX grace period (ms) between `SIGTERM` and `SIGKILL`; unused on Windows. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 69a5f14181..7b23d371a0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -8,7 +8,7 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. Disposal resolves only after child exit. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -24,8 +24,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `cwd` | process cwd | Child process and ACP session working directory. | | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | -| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. | -| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. | +| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | +| `disposeGraceMs` | `3000` | POSIX grace after SIGTERM before SIGKILL; unused on Windows. | ```yaml - id: subagent-acp diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 80766ed831..697be5b98a 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -46,7 +46,7 @@ export interface Config { * before the parent escalates to a signal. */ disposeEofGraceMs?: number - /** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */ + /** POSIX grace period (ms) between `SIGTERM` and `SIGKILL`; unused on Windows. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9416d58865..c9f1c59e10 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -56,9 +56,9 @@ export interface AcpRunSpec { */ disposeEofGraceMs: number /** - * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in - * {@link SubagentRun.dispose}. The plugin fills this from its - * `disposeGraceMs` config. + * POSIX grace period (ms) between `SIGTERM` and `SIGKILL` in + * {@link SubagentRun.dispose}; unused on Windows. The plugin fills this from + * its `disposeGraceMs` config. */ disposeGraceMs: number /** @@ -75,7 +75,7 @@ export interface AcpRunSpec { /** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ +/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** @@ -290,9 +290,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (disposal !== undefined) return disposal request.signal.removeEventListener('abort', onAbort) requestCancel() - // The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces - // from stdin EOF, including the final flush, so this backend uses a wider - // EOF grace before signals escalate. + // The shared platform-aware ladder awaits exit. ACP normally quiesces from + // stdin EOF, including the final flush, so this backend uses a wider EOF + // grace before process termination escalates. disposal = disposeProcess() return disposal }, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e3fd3eda48..a57263eb6c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -263,13 +263,9 @@ describe('dsh-subagent-acp', () => { } }) - it.skipIf(process.platform === 'win32')('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { - // A child that keeps its loop alive past stdin EOF (so the graceful window - // times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier - // — dispose returns there, never reaching the SIGKILL tier. The child touches - // a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if - // dispose had skipped the middle rung (EOF→SIGKILL) the handler would never - // run and the marker would be absent — making this a GENUINE middle-tier guard. + it('terminates a child that ignores EOF using the host platform semantics', async () => { + // POSIX uses the catchable SIGTERM tier and records the marker. Windows has + // no distinct graceful signal, so disposal skips directly to forced exit. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-')) const ready = join(tmp, 'ready') const sigterm = join(tmp, 'sigterm') @@ -283,7 +279,7 @@ describe('dsh-subagent-acp', () => { MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, }, - // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM. + // Tiny EOF grace so the ignored-EOF window elapses quickly. disposeEofGraceMs: 150, disposeGraceMs: 2000, } @@ -294,9 +290,7 @@ describe('dsh-subagent-acp', () => { run.dispose(), new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }), ])).resolves.toBeUndefined() - // The child caught SIGTERM and exited — proof the middle rung fired (not a - // jump straight to the uncatchable SIGKILL). - expect(existsSync(sigterm)).toBe(true) + expect(existsSync(sigterm)).toBe(process.platform !== 'win32') } finally { rmSync(tmp, { recursive: true, force: true }) } diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index a3c0905422..48ce9ff0e4 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -20,13 +20,13 @@ Exit waits over a `ChildProcess`: resolve once the child exits by any code or si ### `disposeChildProcess(child, graces)` -The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): +The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): 1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; -2. `SIGTERM`, then wait `graces.disposeGraceMs`; -3. `SIGKILL`, then await the now-certain exit — a child that ignores EOF and traps `SIGTERM` cannot wedge dispose forever. +2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`; +3. force termination and await exit — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows. -The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. +The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; `disposeGraceMs` is unused on Windows because Node maps `SIGTERM` and `SIGKILL` to the same forced termination. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. ### `createIsolatedConfigDir(prefix, pinnedPath?)` @@ -37,7 +37,7 @@ A per-run isolated config directory for an external CLI child (the target of `CL ## Testing -`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. +`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end. ## Model Experience diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 21bcca788e..360b12a70b 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -98,33 +98,42 @@ export interface DisposeLadderGraces { /** * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce * ON ITS OWN — flush durable state, tear down its own nested subprocesses — - * before the parent escalates to `SIGTERM`. A separate (usually WIDER) + * before the parent escalates to platform termination. A separate (usually WIDER) * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative * child's EOF-driven teardown may itself be waiting on a signal-trapping * grandchild plus a final flush, needing more than one signal-grace of * headroom. */ disposeEofGraceMs: number - /** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */ + /** POSIX tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */ disposeGraceMs: number } /** * Tear a child process down to quiescence, resolving only after exit: close stdin and allow - * cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit. + * cooperative flush, then use the host's graceful and forced termination semantics. POSIX + * sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node + * maps both signals to `TerminateProcess`. * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. + * @param platform - the host platform, injectable for unit coverage. */ -export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { +export async function disposeChildProcess( + child: ChildProcess, + graces: DisposeLadderGraces, + platform: NodeJS.Platform = process.platform, +): Promise { // Already gone: nothing to reap. if (child.exitCode !== null || child.signalCode !== null) return // 1. Close stdin and allow cooperative teardown and durable-state flush. child.stdin?.end() if (await exitsWithin(child, graces.disposeEofGraceMs)) return - // 2. SIGTERM, escalating if the child still does not exit within the grace. - child.kill('SIGTERM') - if (await exitsWithin(child, graces.disposeGraceMs)) return + // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. + if (platform !== 'win32') { + child.kill('SIGTERM') + if (await exitsWithin(child, graces.disposeGraceMs)) return + } // 3. Force-kill and await the (now-certain) exit. child.kill('SIGKILL') await waitForExit(child) diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 8ed3891578..9fedf37722 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -229,7 +229,7 @@ describe('disposeChildProcess', () => { it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') expect(fake.stdinEnded).toBe(true) expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') @@ -237,7 +237,7 @@ describe('disposeChildProcess', () => { it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) // Quiescence, not a request: at resolution the child has ACTUALLY exited // (the exit event landed, despite the scripted post-SIGKILL delay). @@ -246,9 +246,16 @@ describe('disposeChildProcess', () => { it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') expect(fake.kills).toEqual(['SIGTERM']) }) + + it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32') + expect(fake.kills).toEqual(['SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) }) describe('createIsolatedConfigDir', () => { From 462519596860baad18e6f2c2a61d60de82eaee37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:19:33 +0800 Subject: [PATCH 21/46] fix(subagent): bound forced child termination Observe signal errors and bound the final forced-exit edge with disposeGraceMs so a refused or ineffective SIGKILL cannot leave disposal pending forever. Apply the confirmation bound on POSIX and Windows, remove listeners and timers on every outcome, and update the ACP consumer contract plus the generated config catalog. Cover emitted signal errors, synchronous kill exceptions, refused termination, and accepted termination that never reports exit. --- docs/config-catalog.md | 2 +- packages/subagent/subagent-acp/README.md | 4 +- packages/subagent/subagent-acp/src/index.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 6 +- .../subagent/subagent-subprocess/README.md | 4 +- .../subagent/subagent-subprocess/src/index.ts | 58 +++++++++++++---- .../tests/subagent-subprocess.spec.ts | 65 +++++++++++++++++++ 7 files changed, 118 insertions(+), 23 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7921b98f55..dd652c7d45 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -866,7 +866,7 @@ export interface Config { * before the parent escalates to a signal. */ disposeEofGraceMs?: number - /** POSIX grace period (ms) between `SIGTERM` and `SIGKILL`; unused on Windows. */ + /** Termination confirmation window (ms), including forced exit on every platform. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 830335bbb5..240b79c2d7 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -10,7 +10,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. Disposal resolves only after child exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -27,7 +27,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | -| `disposeGraceMs` | `3000` | POSIX grace after SIGTERM before SIGKILL; unused on Windows. | +| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | ```yaml - id: subagent-acp diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 697be5b98a..0a761831ea 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -46,7 +46,7 @@ export interface Config { * before the parent escalates to a signal. */ disposeEofGraceMs?: number - /** POSIX grace period (ms) between `SIGTERM` and `SIGKILL`; unused on Windows. */ + /** Termination confirmation window (ms), including forced exit on every platform. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9f037a170c..09a6ed81e9 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -56,9 +56,9 @@ export interface AcpRunSpec { */ disposeEofGraceMs: number /** - * POSIX grace period (ms) between `SIGTERM` and `SIGKILL` in - * {@link SubagentRun.dispose}; unused on Windows. The plugin fills this from - * its `disposeGraceMs` config. + * Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after + * `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin + * fills this from its `disposeGraceMs` config. */ disposeGraceMs: number /** diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index 4a72f6279b..80be43125e 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -20,9 +20,9 @@ The platform-aware dispose ladder resolves only once the child has ACTUALLY exit 1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; 2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`; -3. force termination and await exit — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows. +3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal. -The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; `disposeGraceMs` is unused on Windows because Node maps `SIGTERM` and `SIGKILL` to the same forced termination. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. +The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index de4702bac9..47a97bafb6 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -51,16 +51,6 @@ export function spawnFailure(child: ChildProcess): Promise { }) } -/** - * Resolve once the child process exits (any code/signal); immediate if it is - * already gone. - * @param child - the child process to await. - */ -function waitForExit(child: ChildProcess): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - /** * Race the child's exit against a timer. Neither outcome leaves anything * behind on the child: the exit listener is removed on timeout and the timer @@ -104,10 +94,49 @@ export interface DisposeLadderGraces { * headroom. */ disposeEofGraceMs: number - /** POSIX tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */ + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after + * `SIGKILL`; Windows applies it after the direct forced termination. + */ disposeGraceMs: number } +/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */ +function forceTerminateWithin(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise((resolve, reject) => { + let accepted = false + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + child.off('exit', onExit) + child.off('error', onError) + } + const settle = (complete: () => void): void => { + if (settled) return + settled = true + cleanup() + complete() + } + const onExit = (): void => { settle(resolve) } + const onError = (error: Error): void => { settle(() => { reject(error) }) } + child.once('exit', onExit) + child.once('error', onError) + const timer = setTimeout(() => { + const disposition = accepted ? 'accepted' : 'refused' + settle(() => { + reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) + }) + }, ms).unref() + try { + accepted = child.kill('SIGKILL') + if (child.exitCode !== null || child.signalCode !== null) settle(resolve) + } catch (error: unknown) { + settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) + } + }) +} + /** * Tear a child process down to quiescence, resolving only after exit: close stdin and allow * cooperative flush, then use the host's graceful and forced termination semantics. POSIX @@ -117,6 +146,8 @@ export interface DisposeLadderGraces { * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. * @param platform - the host platform, injectable for unit coverage. + * @throws When forced termination errors or the child does not report exit within + * `disposeGraceMs`. */ export async function disposeChildProcess( child: ChildProcess, @@ -133,9 +164,8 @@ export async function disposeChildProcess( child.kill('SIGTERM') if (await exitsWithin(child, graces.disposeGraceMs)) return } - // 3. Force-kill and await the (now-certain) exit. - child.kill('SIGKILL') - await waitForExit(child) + // 3. Force-kill and await a bounded exit edge. + await forceTerminateWithin(child, graces.disposeGraceMs) } /** diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 901989e074..7c17333c03 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -234,6 +234,71 @@ describe('disposeChildProcess', () => { expect(fake.kills).toEqual(['SIGKILL']) expect(fake.signalCode).toBe('SIGKILL') }) + + it('propagates a forced-termination error without waiting for the grace', async () => { + const fake = new FakeChild() + const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + fake.emit('error', failure) + return false + }) + + await expect(disposeChildProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, + 'win32', + )).rejects.toBe(failure) + expect(fake.kills).toEqual(['SIGKILL']) + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('wraps a synchronous forced-termination exception and removes its listeners', async () => { + const fake = new FakeChild() + const failure = new Error('invalid signal state') + vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure }) + + await expect(disposeChildProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, + 'win32', + )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure }) + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('bounds a refused forced termination that produces no error or exit', async () => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + return false + }) + + await expect(disposeChildProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 10 }, + 'win32', + )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused') + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('bounds an accepted forced termination that never reports exit', async () => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + return true + }) + + await expect(disposeChildProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 10 }, + 'win32', + )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted') + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) }) describe('createIsolatedConfigDir', () => { From 46580e408329e027414273527bac39dbd39b1650 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:20:11 +0800 Subject: [PATCH 22/46] test(subagent): pin POSIX disposal scenarios Pass an explicit Linux platform to the two remaining SIGTERM-specific ladder tests instead of inheriting the host platform. This keeps their synchronous-exit assertions focused on the POSIX middle and final rungs while the dedicated Windows case continues to verify the direct SIGKILL path. Without the pin, native Windows coverage deterministically expected SIGTERM but observed the intended SIGKILL-only behavior. --- .../subagent-subprocess/tests/subagent-subprocess.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 7c17333c03..e81957baa0 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -200,7 +200,7 @@ describe('disposeChildProcess', () => { it('recognizes a child that exits synchronously on SIGTERM', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') expect(fake.listenerCount('exit')).toBe(0) @@ -217,7 +217,7 @@ describe('disposeChildProcess', () => { it('recognizes a child already gone when the final exit wait begins', async () => { const fake = new FakeChild({ synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) expect(fake.signalCode).toBe('SIGKILL') }) From 2b673bd68dbc41798d138454e4def2b2f265046f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:38:18 +0800 Subject: [PATCH 23/46] fix(fs): preserve Windows DACLs across atomic replacement Copy an existing target's DACL onto the empty staging file before any content is written, then publish with ReplaceFileW so Windows replacement keeps the target security descriptor instead of inheriting the broader parent policy. Keep new-file inheritance and POSIX mode behavior unchanged, retain the already-protected temp when a concurrently removed target requires rename fallback, and translate native errors into Node-style codes for the filesystem error boundary. Add host-independent Win32 binding coverage, native Windows descriptor assertions, package documentation, and a bilingual implemented RFC that supersedes the earlier inheritance-only replacement claim. --- docs/rfc/INDEX.md | 6 + .../2026-07-05-windows-fs-permissions.md | 14 +- ...s-atomic-write-dacl-preservation.i18n.yaml | 6 + ...-windows-atomic-write-dacl-preservation.md | 27 ++++ ...ndows-atomic-write-dacl-preservation.zh.md | 27 ++++ packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/package.json | 1 + packages/fs/fs-local/src/fsio.ts | 34 +++- packages/fs/fs-local/src/win32.ts | 134 ++++++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 89 ++++++++++- packages/fs/fs-local/tests/win32.spec.ts | 145 ++++++++++++++++++ pnpm-lock.yaml | 3 + 12 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml create mode 100644 docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md create mode 100644 docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md create mode 100644 packages/fs/fs-local/src/win32.ts create mode 100644 packages/fs/fs-local/tests/win32.spec.ts diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ceaf70cd87..6ca005be8d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,12 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | +### Bug-fix + +| Title | First proposed | +|---|---| +| [Preserve Windows DACLs during atomic file replacement](implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md) | 2026-07-19 | + ### Simplification | Title | First proposed | diff --git a/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md index 0001e8223c..4d477990cd 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md +++ b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md @@ -2,21 +2,23 @@ Status: implemented +The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). + ## Problem `writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions. -Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL, which this code never sets; a newly created file or directory inherits its DACL from its parent directory. +Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding RFC. ## Decision -Production code is unchanged: no platform fork, no DACL management. The Windows privacy invariant is structural rather than mode-driven — the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit exactly the destination directory's DACL, and write-in-progress content is never exposed more widely than the destination itself. In the typical deployment (a coding agent writing the user's own project tree under `C:\Users\\`) the inherited DACL is owner + SYSTEM + Administrators, matching the POSIX intent. +New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). -Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion because there is no Windows-side code behavior to pin: an ACL check on a `mkdtemp(tmpdir())` fixture would verify Windows DACL inheritance plus the machine's `%TEMP%` ACL — the operating system, not this package — and no change to this package could turn it red. +Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist. ## Alternatives considered -**Explicit protected DACLs.** Granting owner-only access would require per-write FFI or a subprocess, break inheritance, and surprise users whose project directories are deliberately shared. This becomes appropriate only if the threat model includes hostile local readers of broadly accessible target directories. +**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy. **Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile. @@ -24,6 +26,6 @@ Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion bec ## Consequences -POSIX keeps the stronger guarantee: owner-only temp content regardless of the parent directory. Windows guarantees only "no wider than the destination": a target inside a broadly accessible directory (a share, a permissive `D:\` root) gets equally accessible write-in-progress content. The gap is deliberate and documented, not an oversight. +POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists. -Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced at all there — `rename` over it fails before the preserved mode would matter. +Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter. diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml new file mode 100644 index 0000000000..5f37bf3ca3 --- /dev/null +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-windows-atomic-write-dacl-preservation.md: 393ce8a992b8c0b7b580f2c794e098d66e14258e +2026-07-19-windows-atomic-write-dacl-preservation.zh.md: c7a0b6278cf739cc5ef4432d679e48b88b61d198 diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md new file mode 100644 index 0000000000..393ce8a992 --- /dev/null +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md @@ -0,0 +1,27 @@ +# RFC: Preserve Windows DACLs during atomic file replacement + +Status: implemented + +English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md) + +## Problem + +On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement. + +## Decision + +`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target security descriptor and other replacement metadata. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL. + +Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement descriptor. Host-independent binding tests cover Win32 error translation and every native call boundary. + +## Alternatives considered + +**Rely on directory inheritance for replacements.** Rejected because a target may carry a narrower explicit or protected DACL than its parent, so inheritance neither protects staged content nor preserves the target access policy. + +**Use `ReplaceFileW` without protecting the temp.** Rejected because it repairs the final descriptor only after the content has already been written under the staging file's inherited DACL. + +**Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one. + +## Consequences + +Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged. diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md new file mode 100644 index 0000000000..c7a0b6278c --- /dev/null +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md @@ -0,0 +1,27 @@ +# RFC: Windows 原子文件替换期间保留 DACL + +Status: implemented + +[English](2026-07-19-windows-atomic-write-dacl-preservation.md) | 中文 + +## 问题 + +在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。 + +## 决策 + +`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的安全描述符及其他替换元数据。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。 + +Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件的描述符。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。 + +## 备选方案 + +**替换文件时依赖目录继承。** 不予采用,因为目标文件可能带有比父目录更严格的显式或受保护 DACL;目录继承既无法保护暂存内容,也无法保留目标文件的访问策略。 + +**使用 `ReplaceFileW`,但不保护临时文件。** 不予采用,因为这只能在内容已经按暂存文件继承的 DACL 写入之后修复最终描述符。 + +**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。 + +## 影响 + +替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 7c3899e5cb..551492db67 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows the mode bits drive only the read-only attribute, and write-in-progress privacy comes instead from the staging dir inheriting the destination directory's DACL ([Windows write-permission RFC](../../../docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original descriptor survives ([Windows DACL preservation RFC](../../../docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index dd80cb4d9c..ded2a9d55f 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -26,6 +26,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "koffi": "^3.1.0", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 05d957105a..549554043f 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -12,6 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 @@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion { * file before it is renamed over the target. */ export interface FsIoInternals { + /** Override the host platform for native-publication unit coverage. */ + platform?: NodeJS.Platform /** Override the generated private staging-dir name (relative to the target dir). */ tempDirName?: (writePath: string) => string /** Override the generated temp-file name (relative to the private staging dir). */ tempName?: (writePath: string) => string + /** Override the Win32 DACL copy boundary. */ + copyFileDacl?: (source: string, destination: string) => Promise + /** Override the Win32 security-preserving replacement boundary. */ + replaceFile?: (replaced: string, replacement: string) => Promise /** Test hook after the temp file is written/synced but before final chmod+rename. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise } @@ -412,11 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow /** * Atomically replace a file through a private, synced staging file in the same directory. - * POSIX protects the staging directory and file with `0o700` and `0o600`; Windows - * inherits the destination directory's DACL because Node mode bits are synthetic there. + * POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file + * inherits the destination directory's DACL; a replacement copies the existing target's DACL + * onto the empty temp before writing and preserves the target descriptor at publication. * @param absolutePath - destination; missing parent directories are created. * @param content - the full UTF-8 text to write. - * @param mode - final POSIX mode, or `0o600` when omitted; inert on Windows. + * @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file; + * inert as a mode on Windows but identifies replacement security semantics. * @param signal - cancellation checked before the final rename. * @param internals - test seam for pinning temp names and observing the staged file. */ @@ -436,6 +445,9 @@ export async function writeFileAtomic( const stagingDir = join(directory, stagingDirName) const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp` const tempPath = join(stagingDir, tempName) + const platform = internals.platform ?? process.platform + const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32 + const replaceFile = internals.replaceFile ?? replaceFileWin32 let handle: Awaited> | undefined let stagingCreated = false try { @@ -445,6 +457,9 @@ export async function writeFileAtomic( handle = await open(tempPath, 'wx', 0o600) await handle.chmod(0o600) + if (platform === 'win32' && mode !== undefined) { + await copyFileDacl(absolutePath, tempPath) + } await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} }) await handle.sync() await internals.inspectTemp?.({ stagingDir, tempPath }) @@ -453,7 +468,18 @@ export async function writeFileAtomic( handle = undefined throwIfAborted(signal, 'write') - await rename(tempPath, absolutePath) + if (platform === 'win32' && mode !== undefined) { + try { + await replaceFile(absolutePath, tempPath) + } catch (error: unknown) { + // Preserve the old behavior when an external actor removes the observed target during + // staging: the temp already carries that target's protected DACL, so rename recreates it. + if (!isENOENT(error)) throw error + await rename(tempPath, absolutePath) + } + } else { + await rename(tempPath, absolutePath) + } await rm(stagingDir, { recursive: true, force: true }) } catch (error: unknown) { /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */ diff --git a/packages/fs/fs-local/src/win32.ts b/packages/fs/fs-local/src/win32.ts new file mode 100644 index 0000000000..6f459898a9 --- /dev/null +++ b/packages/fs/fs-local/src/win32.ts @@ -0,0 +1,134 @@ +/** + * Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so + * non-Windows processes never open Win32 libraries. + * @module @deepseek-ai/dsh-fs-local/win32 + */ + +import { toNamespacedPath } from 'node:path' + +type GetFileSecurityW = ( + path: string, + requestedInformation: number, + descriptor: Buffer | null, + length: number, + needed: [number], +) => number +type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number +type ReplaceFileW = ( + replaced: string, + replacement: string, + backup: null, + flags: number, + exclude: null, + reserved: null, +) => number +type GetLastError = () => number + +interface Win32Bindings { + getFileSecurityW: GetFileSecurityW + setFileSecurityW: SetFileSecurityW + replaceFileW: ReplaceFileW + getLastError: GetLastError +} + +interface Win32ErrnoException extends NodeJS.ErrnoException { + win32Code: number +} + +const DACL_SECURITY_INFORMATION = 0x00000004 +const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000 +const ERROR_FILE_NOT_FOUND = 2 +const ERROR_PATH_NOT_FOUND = 3 +const ERROR_ACCESS_DENIED = 5 + +let bindings: Win32Bindings | undefined + +async function win32(): Promise { + if (bindings !== undefined) return bindings + const koffi = (await import('koffi')).default + const advapi32 = koffi.load('advapi32.dll') + const kernel32 = koffi.load('kernel32.dll') + bindings = { + getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW, + setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW, + replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW, + getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError, + } + return bindings +} + +function errnoCode(win32Code: number): string { + switch (win32Code) { + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + return 'ENOENT' + case ERROR_ACCESS_DENIED: + return 'EACCES' + default: + return 'EIO' + } +} + +function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException { + const code = errnoCode(win32Code) + const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException + error.code = code + error.errno = win32Code + error.syscall = syscall + error.path = path + error.win32Code = win32Code + return error +} + +/** + * Read a file's self-relative DACL security descriptor. + * @param path - existing file whose DACL is read. + * @returns a descriptor buffer accepted by `SetFileSecurityW`. + */ +export async function readFileDaclWin32(path: string): Promise { + const api = await win32() + const nativePath = toNamespacedPath(path) + const needed: [number] = [0] + api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed) + if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path) + + const descriptor = Buffer.alloc(needed[0]) + if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) { + throw win32Error('GetFileSecurityW', api.getLastError(), path) + } + return descriptor.subarray(0, needed[0]) +} + +/** + * Copy an existing file's DACL onto another file and protect it from staging-parent inheritance. + * The destination must still be empty when confidentiality depends on this call. + * @param source - existing file whose DACL is copied. + * @param destination - existing file that receives the protected DACL. + */ +export async function copyFileDaclWin32(source: string, destination: string): Promise { + const descriptor = await readFileDaclWin32(source) + const api = await win32() + const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0 + if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) { + throw win32Error('SetFileSecurityW', api.getLastError(), destination) + } +} + +/** + * Replace a Windows file while preserving the replaced file's ACL and other replace metadata. + * @param replaced - existing destination file. + * @param replacement - closed staging file on the same volume. + */ +export async function replaceFileWin32(replaced: string, replacement: string): Promise { + const api = await win32() + if (api.replaceFileW( + toNamespacedPath(replaced), + toNamespacedPath(replacement), + null, + 0, + null, + null, + ) === 0) { + throw win32Error('ReplaceFileW', api.getLastError(), replaced) + } +} diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 559ef86563..d292f0bfb7 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -6,7 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer } from 'node:net' @@ -23,6 +23,7 @@ import { writeFileAtomic, } from '../src/fsio.ts' import type { LocalTarget } from '../src/fsio.ts' +import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string @@ -367,15 +368,15 @@ describe('streamWholeText', () => { }) }) -// Windows drives only the read-only attribute through `chmod` and reports -// synthetic `stat` mode bits, so mode assertions are POSIX-only; on Windows -// write-in-progress privacy comes from the destination directory's inherited -// DACL (docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md). +// Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode +// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately. const posixModes = process.platform !== 'win32' describe('writeFileAtomic — temp-file safety', () => { it('writes through a private staging dir and owner-only temp file', async () => { const file = join(dir, 'a.txt') + await writeFile(file, 'old') + if (posixModes) await chmod(file, 0o640) let inspected = false await writeFileAtomic(file, 'hello', 0o640, undefined, { inspectTemp: async ({ stagingDir, tempPath }) => { @@ -395,6 +396,84 @@ describe('writeFileAtomic — temp-file safety', () => { expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) + it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => { + const file = join(dir, 'protected.txt') + await writeFile(file, 'old') + await copyFileDaclWin32(file, file) + const expectedDacl = await readFileDaclWin32(file) + + await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, { + inspectTemp: async ({ tempPath }) => { + expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl) + }, + }) + + expect(await readFile(file, 'utf8')).toBe('new') + expect(await readFileDaclWin32(file)).toEqual(expectedDacl) + }) + + it('copies a Windows target DACL before content and publishes through secure replacement', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'old') + const calls: string[] = [] + + await writeFileAtomic(file, 'new', 0o666, undefined, { + platform: 'win32', + copyFileDacl: async (source, temp) => { + calls.push(`copy:${source}`) + expect(await readFile(temp, 'utf8')).toBe('') + }, + replaceFile: async (target, temp) => { + calls.push(`replace:${target}`) + await rename(temp, target) + }, + }) + + expect(calls).toEqual([`copy:${file}`, `replace:${file}`]) + expect(await readFile(file, 'utf8')).toBe('new') + }) + + it('creates a new Windows file through directory inheritance without replacement calls', async () => { + const file = join(dir, 'new.txt') + const unexpected = async (): Promise => { throw new Error('unexpected native replacement call') } + + await writeFileAtomic(file, 'new', undefined, undefined, { + platform: 'win32', + copyFileDacl: unexpected, + replaceFile: unexpected, + }) + + expect(await readFile(file, 'utf8')).toBe('new') + }) + + it('recreates a vanished Windows target with the already-protected temp', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'old') + const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' }) + + await writeFileAtomic(file, 'new', 0o666, undefined, { + platform: 'win32', + copyFileDacl: () => Promise.resolve(), + replaceFile: async () => { throw missing }, + }) + + expect(await readFile(file, 'utf8')).toBe('new') + }) + + it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'old') + const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' }) + + await expect(writeFileAtomic(file, 'new', 0o666, undefined, { + platform: 'win32', + copyFileDacl: () => Promise.resolve(), + replaceFile: async () => { throw denied }, + })).rejects.toBe(denied) + expect(await readFile(file, 'utf8')).toBe('old') + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined) diff --git a/packages/fs/fs-local/tests/win32.spec.ts b/packages/fs/fs-local/tests/win32.spec.ts new file mode 100644 index 0000000000..dc2b69ea82 --- /dev/null +++ b/packages/fs/fs-local/tests/win32.spec.ts @@ -0,0 +1,145 @@ +/** Host-independent binding tests for the Win32 DACL and replacement helpers. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' + +type GetFileSecurityW = ( + path: string, + requestedInformation: number, + descriptor: Buffer | null, + length: number, + needed: [number], +) => number +type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number +type ReplaceFileW = ( + replaced: string, + replacement: string, + backup: null, + flags: number, + exclude: null, + reserved: null, +) => number + +interface NativeMock { + getFileSecurityW: GetFileSecurityW + setFileSecurityW: SetFileSecurityW + replaceFileW: ReplaceFileW + getLastError: () => number +} + +async function importWithNative(native: NativeMock): Promise { + vi.resetModules() + vi.doMock('koffi', () => ({ + default: { + load: () => ({ + func: (definition: string) => { + if (definition.includes('GetFileSecurityW')) return native.getFileSecurityW + if (definition.includes('SetFileSecurityW')) return native.setFileSecurityW + if (definition.includes('ReplaceFileW')) return native.replaceFileW + if (definition.includes('GetLastError')) return native.getLastError + throw new Error(`unexpected native function: ${definition}`) + }, + }), + }, + })) + return import('../src/win32.ts') +} + +function successfulNative(descriptor: Buffer): NativeMock & { installed: Buffer[]; replacements: string[][] } { + let lastError = 0 + const installed: Buffer[] = [] + const replacements: string[][] = [] + return { + installed, + replacements, + getLastError: () => lastError, + getFileSecurityW: (_path, _requested, output, _length, needed) => { + needed[0] = descriptor.length + if (output === null) { + lastError = 122 + return 0 + } + descriptor.copy(output) + lastError = 0 + return 1 + }, + setFileSecurityW: (_path, information, value) => { + expect(information).toBe(0x80000004) + installed.push(Buffer.from(value)) + lastError = 0 + return 1 + }, + replaceFileW: (replaced, replacement, backup, flags, exclude, reserved) => { + expect([backup, flags, exclude, reserved]).toEqual([null, 0, null, null]) + replacements.push([replaced, replacement]) + lastError = 0 + return 1 + }, + } +} + +afterEach(() => { + vi.doUnmock('koffi') + vi.resetModules() +}) + +describe('Windows file-security helpers', () => { + it('reads and installs a protected DACL before replacing the destination', async () => { + const descriptor = Buffer.from([1, 2, 3, 4]) + const native = successfulNative(descriptor) + const { copyFileDaclWin32, readFileDaclWin32, replaceFileWin32 } = await importWithNative(native) + + expect(await readFileDaclWin32('source')).toEqual(descriptor) + await copyFileDaclWin32('source', 'temp') + expect(native.installed).toEqual([descriptor]) + await replaceFileWin32('target', 'temp') + expect(native.replacements).toEqual([['target', 'temp']]) + }) + + it('maps descriptor-size probe failures to Node-style codes', async () => { + const cases = [[2, 'ENOENT'], [3, 'ENOENT'], [5, 'EACCES'], [9999, 'EIO']] as const + for (const [win32Code, code] of cases) { + const native = successfulNative(Buffer.from([1])) + native.getFileSecurityW = (_path, _requested, _output, _length, needed) => { + needed[0] = 0 + return 0 + } + native.getLastError = () => win32Code + const { readFileDaclWin32 } = await importWithNative(native) + await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code, win32Code, path: 'source' }) + } + }) + + it('surfaces a descriptor read failure after the size probe', async () => { + const native = successfulNative(Buffer.from([1, 2])) + native.getFileSecurityW = (_path, _requested, _output, _length, needed) => { + needed[0] = 2 + return 0 + } + native.getLastError = () => 5 + const { readFileDaclWin32 } = await importWithNative(native) + + await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code: 'EACCES', syscall: 'GetFileSecurityW' }) + }) + + it('surfaces DACL installation and replacement failures', async () => { + const setFailure = successfulNative(Buffer.from([1])) + setFailure.setFileSecurityW = () => 0 + setFailure.getLastError = () => 5 + const setModule = await importWithNative(setFailure) + await expect(setModule.copyFileDaclWin32('source', 'temp')).rejects.toMatchObject({ + code: 'EACCES', + syscall: 'SetFileSecurityW', + path: 'temp', + }) + + const replaceFailure = successfulNative(Buffer.from([1])) + replaceFailure.replaceFileW = () => 0 + replaceFailure.getLastError = () => 2 + const replaceModule = await importWithNative(replaceFailure) + await expect(replaceModule.replaceFileWin32('target', 'temp')).rejects.toMatchObject({ + code: 'ENOENT', + syscall: 'ReplaceFileW', + path: 'target', + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c591034cc..0ed8c8449c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,6 +819,9 @@ importers: packages/fs/fs-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 schemastery: specifier: ^3.18.0 version: 3.18.0 From f110c5e08377f4074a0ec5927226f72c821a742b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:41:46 +0800 Subject: [PATCH 24/46] fix(acp-snapshot): accept Windows termination exit races Treat a fallback kill refusal as successful termination when the child already carries an OS exit marker. Windows maps Node's supported signal names to forced termination, so the requested signal can end the process between the launcher error race and its fallback SIGKILL. Drain inherited stdio, the ACP parser, and in-flight callbacks before propagating the original child error in either exit-race path. Preserve AggregateError reporting only for a refused fallback while the process is still live, and add a deterministic cross-platform regression for that ordering. --- packages/support/acp-snapshot/src/launcher.ts | 17 ++++++++++--- .../acp-snapshot/tests/harness.spec.ts | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index de5082eca4..e39c6552f0 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -231,6 +231,15 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe return } + const propagateFailureAfterDrain = async (): Promise => { + await drained + closeUpdateStream() + throw failure + } + // Windows implements the supported signal names as forced termination. The exit markers + // may therefore arrive after the error wins the race above but before fallback begins. + if (!isRunning(child)) return propagateFailureAfterDrain() + // An `error` after spawn is not an exit edge: in particular, a failed // signal can leave the subprocess live. Force termination, await the // already-observed exit edge, and only then propagate the child error so @@ -240,6 +249,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe child.once('error', observeFallbackError) if (!child.kill('SIGKILL')) { child.off('error', observeFallbackError) + // A successful earlier signal may win between the live check and this fallback call. + // In that case `kill()` correctly reports no process to signal; the original child error + // remains the shutdown result once inherited stdio and callbacks have drained. + if (!isRunning(child)) return propagateFailureAfterDrain() closeUpdateStream() throw new AggregateError( [failure, new Error('Fallback SIGKILL was not accepted by the child process')], @@ -258,9 +271,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe 'ACP test agent failed and fallback termination was refused', ) } - await drained - closeUpdateStream() - throw failure + return propagateFailureAfterDrain() }, } } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 5589858fe9..2c4478e158 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -176,6 +176,30 @@ describe('runScenario', () => { } }) + it('preserves the child error when fallback refusal races with an exit marker', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGTERM') return true + originalKill('SIGKILL') + Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' }) + return false + }) + try { + launched.child.emit('error', childFailure) + await expect(launched.close('SIGTERM')).rejects.toBe(childFailure) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL') + } + }) + it('rejects promptly when fallback termination emits an error', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) From 0f4bc645dab2f4f03c45e59e0feaf36f64767a7c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:42:59 +0800 Subject: [PATCH 25/46] test(acp-snapshot): inherit descendant stdio portably Pass the fake descendant the parent process's stdout and stderr stream handles instead of Unix-style numeric file descriptors. This lets Windows duplicate the live ACP and diagnostic pipes so launcher shutdown can prove that inherited handles, buffered frames, and stderr all drain after the parent exits. Observe the pending update promise before initiating shutdown as well, preventing a missing late frame from becoming a transient unhandled rejection before the assertion reports the fixture failure. --- .../support/acp-snapshot/tests/fixtures/fake-acp-agent.ts | 4 +++- packages/support/acp-snapshot/tests/harness.spec.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 5dd5524ed0..647ffbb5d9 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -266,7 +266,9 @@ function flushLogsAndExit(): void { `setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`, `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, ].join(';') - spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref() + spawn(process.execPath, ['-e', code], { + stdio: ['ignore', process.stdout, process.stderr], + }).unref() } process.exit(0) } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 2c4478e158..08b069338c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -139,6 +139,9 @@ describe('runScenario', () => { update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' && update.content.text === 'late inherited stdout') + // Arm rejection handling before close may exhaust the stream; the later assertion still + // observes the original promise and turns a missing inherited frame into the test failure. + void lateUpdate.catch(() => undefined) await launched.close() From 3dc82b1e870e2f1bd732093909d23ead9e605d14 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:43:58 +0800 Subject: [PATCH 26/46] test(snapshot): refresh Windows workspace permission prose Update the workspace-edit Windows sidecar to the current permission preset description emitted by the ACP session configuration. The shared golden already carried this contract; only the native-Windows transcript retained the superseded wording. Leave the platform-specific backslash path rendering unchanged so the sidecar continues to pin the one intentional Windows transcript difference. --- .../tests/snapshots/workspace-edit/stdout.golden.windows.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl index 5f8762adcd..7438c3f43f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.windows.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} From 4bf2ef89c4d6c5164140e5e522612b19444a8008 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:44:47 +0800 Subject: [PATCH 27/46] fix(jsonl): bind MoveFileExW with the Win32 BOOL ABI Declare MoveFileExW's return value as Koffi int and model it as a numeric 32-bit Win32 BOOL. The previous Koffi bool declaration represented a one-byte C boolean and could read the native return register with the wrong ABI. Test zero and nonzero results explicitly and assert the binding result type while preserving the existing write-through flags, error translation, and durable directory race behavior. --- .../session-persistence-jsonl/src/win32.ts | 6 ++--- .../tests/win32.spec.ts | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index 143f230ea3..a8c1b6fb8d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -14,7 +14,7 @@ import { mkdtemp, rm, stat } from 'node:fs/promises' import { basename, join, parse, resolve, toNamespacedPath } from 'node:path' -type MoveFileExW = (existing: string, replacement: string, flags: number) => boolean +type MoveFileExW = (existing: string, replacement: string, flags: number) => number type GetLastError = () => number interface Win32Bindings { @@ -44,7 +44,7 @@ async function win32(): Promise { const koffi = (await import('koffi')).default const kernel32 = koffi.load('kernel32.dll') bindings = { - moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'bool', ['str16', 'str16', 'uint']) as MoveFileExW, + moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW, getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError, } return bindings @@ -113,7 +113,7 @@ async function assertDirectory(path: string): Promise { export async function publishNewFileWin32(existing: string, replacement: string): Promise { const api = await win32() const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) - if (!ok) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) + if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) } /** diff --git a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts index 760eb3d455..b4a2d11f28 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts @@ -19,7 +19,7 @@ const ERROR_FILE_EXISTS = 80 const ERROR_INVALID_NAME = 123 const ERROR_ALREADY_EXISTS = 183 -type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => boolean +type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number const roots: string[] = [] @@ -42,14 +42,15 @@ async function importWithMove(moveFileExW: MoveFileExW): Promise { lastError = code } const move: MoveFileExW = (existing, replacement, flags, setError) => { const ok = moveFileExW(existing, replacement, flags, setError) - lastError = ok ? 0 : lastError + lastError = ok === 0 ? lastError : 0 return ok } return { default: { load: () => ({ - func: (_convention: string, name: string) => { + func: (_convention: string, name: string, result: string) => { if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => { + expect(result).toBe('int') const ok = move(existing, replacement, flags, setLastError) return ok } @@ -68,7 +69,7 @@ async function importWithError(code: number): Promise ({ func: (_convention: string, name: string) => { - if (name === 'MoveFileExW') return () => false + if (name === 'MoveFileExW') return () => 0 return () => code }, }), @@ -82,10 +83,10 @@ async function importWithFilesystemMove(): Promise { if (to === raced) { mkdirSync(to) setLastError(ERROR_ALREADY_EXISTS) - return false + return 0 } - if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return false } - if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return false } + if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 } + if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 } renameSync(from, to) - return true + return 1 }) await ensureDurableDirectoryWin32(join(root, 'a', 'b')) From c660048759fa0e20bc4f7cd953652176cd8f5950 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:52:42 +0800 Subject: [PATCH 28/46] test(subagent): cover late forced-wait exit markers Exercise both exitCode and signalCode arriving after the SIGTERM grace begins but before the bounded SIGKILL confirmation helper starts. This pins the fast path that avoids signaling an already-terminated child and restores the package's per-file 100% branch and statement coverage. Keep the marker transition deterministic by withholding the synthetic exit event, matching the OS state race the defensive pre-check exists to absorb. --- .../tests/subagent-subprocess.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index e81957baa0..d674937e92 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -222,6 +222,21 @@ describe('disposeChildProcess', () => { expect(fake.signalCode).toBe('SIGKILL') }) + it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + queueMicrotask(() => { + if (marker === 'exitCode') fake.exitCode = 0 + else fake.signalCode = 'SIGTERM' + }) + return true + }) + + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM']) + }) + it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') From e9aac53fdec49c951db97f8a1c2e677c35818d98 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:53:41 +0800 Subject: [PATCH 29/46] test(acp-snapshot): cover pre-fallback Windows exit state Model the requested signal setting a child termination marker before the launcher begins fallback handling. The regression proves close drains inherited stdio and propagates the original process error without sending a redundant SIGKILL. This complements the post-check fallback-refusal race and restores the launcher's required 100% per-file statement and branch coverage. --- .../acp-snapshot/tests/harness.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 08b069338c..61b40010df 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -179,6 +179,29 @@ describe('runScenario', () => { } }) + it('preserves the child error when the requested signal sets an exit marker', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + expect(signal).toBe('SIGTERM') + originalKill('SIGKILL') + Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' }) + return true + }) + try { + launched.child.emit('error', childFailure) + await expect(launched.close('SIGTERM')).rejects.toBe(childFailure) + expect(kill).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL') + } + }) + it('preserves the child error when fallback refusal races with an exit marker', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) From 0f8d0082e4b916f2afed3fec850853a9d26dfb77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:20:59 +0800 Subject: [PATCH 30/46] fix(tui): stabilize Windows terminal snapshots Treat an absolute path.relative() result as a cross-volume path instead of incorrectly abbreviating it beneath the user's home directory. Allow embeddings to project a logical footer cwd without changing the operational session cwd. The recorded-session harness now uses a POSIX-shaped display alias for both the footer and filesystem result paths, preserving the existing pre-normalization layout width on every host. Keep runtime-provided labels behind terminal-control escaping, cover that boundary, and document the embedding contract. --- examples/tui-agent/tests/tui.snapshot.ts | 35 ++++++++++++++++++++---- packages/ui/tui/README.md | 2 ++ packages/ui/tui/src/index.ts | 27 ++++++++++++++---- packages/ui/tui/tests/harness.ts | 9 ++++-- packages/ui/tui/tests/tui.spec.ts | 7 +++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index c0537021e8..5c443ee474 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' @@ -106,6 +106,13 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode { const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) const observedScenarios = new Set() +function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string { + const rel = relative(cwd, displayPath) + if (rel === '') return displayCwd + if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath + return `${displayCwd}/${rel.split(sep).join('/')}` +} + function scenarioDir(scenario: Scenario): string { return join(SNAPSHOTS_DIR, scenario.name) } @@ -136,9 +143,10 @@ function rawSessionLog(session: Session): string { ].join('\n') } -function normalizeTerminalSnapshot(snapshot: string, cwd: string): string { +function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { return snapshot .split(`/private${cwd}`).join('/workspace/project') + .split(displayCwd).join('/workspace/project') .split(cwd).join('/workspace/project') .replace(UUID_RE, '{{uuid}}') } @@ -157,9 +165,20 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise { async function mountScenarioContext( scenario: Scenario, cwd: string, + displayCwd: string, fixtureFile: string, childFiles: string[], ): Promise { + class SnapshotLocalFileSystem extends LocalFileSystem { + override async resolve( + path: string, + opts?: { cwd?: string; signal?: AbortSignal }, + ): Promise>> { + const target = await super.resolve(path, opts) + return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) } + } + } + const ctx = new Context() await ctx.plugin(AgentCore, { agents: [], @@ -169,7 +188,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) - await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) @@ -207,6 +226,7 @@ async function runScenario(scenario: Scenario): Promise { expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) + const displayCwd = `/tmp/${basename(cwd)}` let ctx: Context | undefined let controller: ReturnType | undefined const terminal = new HeadlessTerminal(100, 36) @@ -215,7 +235,7 @@ async function runScenario(scenario: Scenario): Promise { const source = join(scenarioDir(scenario), 'workspace') await cp(source, cwd, { recursive: true }) } - ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles) + ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = [] @@ -235,7 +255,11 @@ async function runScenario(scenario: Scenario): Promise { title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, maxToolOutputLines: 8, - }, { terminal, exit: () => {} }) + }, { + terminal, + exit: () => {}, + formatCwd: () => displayCwd, + }) await settleTerminal(terminal) for (const prompt of prompts) { @@ -266,6 +290,7 @@ async function runScenario(scenario: Scenario): Promise { const snapshot = normalizeTerminalSnapshot( await terminal.snapshot({ includeScrollback: true }), cwd, + displayCwd, ) await handle.dispose() const children = disposedSessions diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index e426640ae5..34987c5d55 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,6 +8,8 @@ This package owns interactive terminal presentation and input only. It injects ` The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. + Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 0906b7bc9f..210ff10071 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -6,7 +6,7 @@ */ import { homedir } from 'node:os' -import { relative, resolve, sep } from 'node:path' +import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, @@ -135,6 +135,12 @@ export interface TuiRuntime { terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void + /** + * Override the footer's logical working-directory label without changing the session directory used by tools. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped label; the TUI makes terminal controls visible. + */ + formatCwd?: (cwd: string | undefined) => string } /** @@ -608,8 +614,10 @@ function formatCwd(cwd: string | undefined): string { const home = homedir() const rel = relative(resolve(home), resolve(cwd)) if (rel === '') return '~' - if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`) - return displayText(cwd) + /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ + if (isAbsolute(rel)) return cwd + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` + return cwd } function sessionTokens(session: Session): { input: number; output: number } { @@ -630,13 +638,15 @@ class FooterComponent implements Component { private readonly toolsExpanded: () => boolean, private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, + private readonly cwdFormatter: TuiRuntime['formatCwd'], ) {} invalidate(): void {} render(width: number): string[] { const { input, output } = this.tokens() - const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` + const cwd = this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd) + const left = `${displayText(cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` const leftStyled = this.palette.dim(left) const available = Math.max(0, width - visibleWidth(left) - 2) @@ -843,7 +853,14 @@ export function createTuiChat( const welcome = config.welcome ?? 'ready.' const header = new HeaderComponent(agent, welcome, palette) - const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + const footer = new FooterComponent( + agent, + palette, + () => toolsExpanded, + () => showReasoning, + () => tokens, + runtime.formatCwd, + ) ui.addChild(header) ui.addChild(chat) ui.addChild(statusContainer) diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..7cef8ed185 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { createTuiChat, type Config } from '../src/index.ts' +import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -21,6 +21,7 @@ export interface TuiHarnessOptions { configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null + formatCwd?: TuiRuntime['formatCwd'] } export interface TuiHarness void> { @@ -95,7 +96,11 @@ export async function createTuiTestHarness { const outsideResult = await setup({ cwd: '/opt' }) expect(outsideResult.terminal.output).toContain('/opt') await dispose(outsideResult) + + const logicalResult = await setup({ + cwd: '/host/worktree', + formatCwd: cwd => `logical:${cwd}\x1b`, + }) + expect(logicalResult.terminal.output).toContain('logical:/host/worktree\\x1b') + await dispose(logicalResult) }) it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { From a6b8ce456a94de5d19d29228fe4ed19903481280 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:25:15 +0800 Subject: [PATCH 31/46] test(fs): compare Windows DACL access policy ReplaceFileW preserves ACLs by merging security information, which may reserialize auto-inheritance state and duplicate equivalent ACEs. Compare the final ordered, de-duplicated ACE policy instead of requiring byte-identical self-relative descriptor buffers. Update the host-independent binding assertion to expect the namespaced absolute paths that the Win32 boundary actually receives, and align the package and bilingual RFC contracts with the semantic DACL guarantee. --- ...-windows-atomic-write-dacl-preservation.md | 4 ++-- ...ndows-atomic-write-dacl-preservation.zh.md | 4 ++-- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/tests/fsio.spec.ts | 24 ++++++++++++++++++- packages/fs/fs-local/tests/win32.spec.ts | 3 ++- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md index 393ce8a992..13ec1546ce 100644 --- a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md @@ -10,9 +10,9 @@ On Windows, creating the staging directory and temp file under the target's pare ## Decision -`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target security descriptor and other replacement metadata. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL. +`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL. -Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement descriptor. Host-independent binding tests cover Win32 error translation and every native call boundary. +Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. ## Alternatives considered diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md index c7a0b6278c..ca72ec2213 100644 --- a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的安全描述符及其他替换元数据。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。 +`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。 -Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件的描述符。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。 +Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。 ## 备选方案 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 551492db67..e8f57e5084 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original descriptor survives ([Windows DACL preservation RFC](../../../docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation RFC](../../../docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index d292f0bfb7..15588e40b9 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -372,6 +372,28 @@ describe('streamWholeText', () => { // bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately. const posixModes = process.platform !== 'win32' +function daclAcePolicy(descriptor: Buffer): string[] { + const daclOffset = descriptor.readUInt32LE(16) + if (daclOffset === 0) return [] + const aceCount = descriptor.readUInt16LE(daclOffset + 4) + const policy: string[] = [] + const seen = new Set() + let offset = daclOffset + 8 + for (let index = 0; index < aceCount; index++) { + const size = descriptor.readUInt16LE(offset + 2) + const ace = Buffer.from(descriptor.subarray(offset, offset + size)) + // INHERITED_ACE records provenance, not the entry's access policy. + ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1) + const key = ace.toString('hex') + if (!seen.has(key)) { + seen.add(key) + policy.push(key) + } + offset += size + } + return policy +} + describe('writeFileAtomic — temp-file safety', () => { it('writes through a private staging dir and owner-only temp file', async () => { const file = join(dir, 'a.txt') @@ -409,7 +431,7 @@ describe('writeFileAtomic — temp-file safety', () => { }) expect(await readFile(file, 'utf8')).toBe('new') - expect(await readFileDaclWin32(file)).toEqual(expectedDacl) + expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl)) }) it('copies a Windows target DACL before content and publishes through secure replacement', async () => { diff --git a/packages/fs/fs-local/tests/win32.spec.ts b/packages/fs/fs-local/tests/win32.spec.ts index dc2b69ea82..4a8687d9e8 100644 --- a/packages/fs/fs-local/tests/win32.spec.ts +++ b/packages/fs/fs-local/tests/win32.spec.ts @@ -1,5 +1,6 @@ /** Host-independent binding tests for the Win32 DACL and replacement helpers. */ +import { toNamespacedPath } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' type GetFileSecurityW = ( @@ -92,7 +93,7 @@ describe('Windows file-security helpers', () => { await copyFileDaclWin32('source', 'temp') expect(native.installed).toEqual([descriptor]) await replaceFileWin32('target', 'temp') - expect(native.replacements).toEqual([['target', 'temp']]) + expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]]) }) it('maps descriptor-size probe failures to Node-style codes', async () => { From 5d6b589922b0fde638d8ae855f2db4356f601d3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:28:19 +0800 Subject: [PATCH 32/46] fix(acp-snapshot): await delayed Windows exit markers A successful Windows termination request can end the process before Node publishes exitCode or signalCode. If a child error wins the shutdown race, give that accepted exit a bounded observation window before escalating or reporting fallback refusal. Cover a delayed real exit edge, preserve prompt refusal behavior for a genuinely live child, and document the launcher grace without weakening the complete stdio and parser drain boundary. --- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/launcher.ts | 17 ++++++++++++-- .../acp-snapshot/tests/harness.spec.ts | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 43a315e230..53289fda35 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the golden and purity check, and harvests every persisted session JSONL (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario shared golden and re-persisted-log compares, optional Windows-native stdout sidecars, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index e39c6552f0..441ab463d7 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -21,6 +21,8 @@ import { } from '@agentclientprotocol/sdk' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +const EXIT_MARKER_GRACE_MS = 250 + /** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */ export interface AgentUnderTest { /** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */ @@ -238,7 +240,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe } // Windows implements the supported signal names as forced termination. The exit markers // may therefore arrive after the error wins the race above but before fallback begins. - if (!isRunning(child)) return propagateFailureAfterDrain() + if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain() // An `error` after spawn is not an exit edge: in particular, a failed // signal can leave the subprocess live. Force termination, await the @@ -252,7 +254,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // A successful earlier signal may win between the live check and this fallback call. // In that case `kill()` correctly reports no process to signal; the original child error // remains the shutdown result once inherited stdio and callbacks have drained. - if (!isRunning(child)) return propagateFailureAfterDrain() + if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain() closeUpdateStream() throw new AggregateError( [failure, new Error('Fallback SIGKILL was not accepted by the child process')], @@ -281,6 +283,17 @@ function waitForExit(child: ChildProcessWithoutNullStreams): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } +/** Give an accepted Windows termination request a bounded window to publish its exit marker. */ +function exitMarkerWithinGrace(exited: Promise): Promise { + return Promise.race([ + exited.then(() => true), + new Promise((resolve) => { + const timer = setTimeout(() => { resolve(false) }, EXIT_MARKER_GRACE_MS) + timer.unref() + }), + ]) +} + /** Whether the child still lacks either OS termination marker. */ function isRunning(child: ChildProcessWithoutNullStreams): boolean { return child.exitCode === null && child.signalCode === null diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 61b40010df..5d12b2da97 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -202,6 +202,28 @@ describe('runScenario', () => { } }) + it('preserves the child error when the requested signal publishes its exit marker later', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + expect(signal).toBe('SIGTERM') + setTimeout(() => { originalKill('SIGKILL') }, 10) + return true + }) + try { + launched.child.emit('error', childFailure) + await expect(launched.close('SIGTERM')).rejects.toBe(childFailure) + expect(kill).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL') + } + }) + it('preserves the child error when fallback refusal races with an exit marker', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) From 97f9ec7c19f7651b06c4cd79f875510c64b99992 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:29:31 +0800 Subject: [PATCH 33/46] test(acp-snapshot): inherit descendant pipes portably Use Node's explicit inherit stdio mode for the fake descendant instead of passing the parent process stream objects as child descriptors. This keeps the grandchild's stdout and stderr handles open across the fake ACP parent's exit on Windows, so launcher shutdown must drain the late buffered update and stderr bytes just as it does on POSIX. --- packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 647ffbb5d9..fbac6dfa10 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -267,7 +267,7 @@ function flushLogsAndExit(): void { `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, ].join(';') spawn(process.execPath, ['-e', code], { - stdio: ['ignore', process.stdout, process.stderr], + stdio: ['ignore', 'inherit', 'inherit'], }).unref() } process.exit(0) From 37d2556a7bebfd6aeceba1932e0f039f66ae7ae9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:33:08 +0800 Subject: [PATCH 34/46] docs(fs): record the revised DACL translation pair Update the bilingual pairing checksum after the English and Chinese Windows DACL RFCs were revised together to describe semantic ACE-policy comparison. This restores the repository's recorded translation-consistency contract without changing either document's content. --- ...026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml index 5f37bf3ca3..e388c22c18 100644 --- a/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml +++ b/docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-windows-atomic-write-dacl-preservation.md: 393ce8a992b8c0b7b580f2c794e098d66e14258e -2026-07-19-windows-atomic-write-dacl-preservation.zh.md: c7a0b6278cf739cc5ef4432d679e48b88b61d198 +2026-07-19-windows-atomic-write-dacl-preservation.md: 13ec1546ce3a739039045ab5db9b935a83e84098 +2026-07-19-windows-atomic-write-dacl-preservation.zh.md: ca72ec22132777a12f1fcdc1b7f8816a710c0845 From b1b076f99333046ad4c6751dc031552d63056b78 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:49:48 +0800 Subject: [PATCH 35/46] test(acp-snapshot): cover accepted fallback termination Drive the shutdown path where the requested signal reports a child error without exiting, the bounded marker grace expires, and fallback SIGKILL is accepted. Assert that both signals are attempted and that close drains the successful fallback exit before preserving the original child error, restoring per-file 100% branch and line coverage for the launcher. --- .../acp-snapshot/tests/harness.spec.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 5d12b2da97..64076c1217 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -248,6 +248,28 @@ describe('runScenario', () => { } }) + it('preserves the child error after accepted fallback termination drains', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGTERM') return true + return originalKill('SIGKILL') + }) + try { + launched.child.emit('error', childFailure) + await expect(launched.close('SIGTERM')).rejects.toBe(childFailure) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL') + } + }) + it('rejects promptly when fallback termination emits an error', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) From a246afa33a424d831b6f5c59fac7f07aebdf4a03 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:14:00 +0800 Subject: [PATCH 36/46] test(acp-snapshot): detach the inherited-stdio fixture Windows does not guarantee that an ordinary child process will continue after its parent exits. The fake ACP agent exited immediately after spawning its late-output descendant, so Windows could tear down that descendant and close the protocol stream before the delayed ACP update was written. Launch the descendant in detached mode while continuing to inherit stdout and stderr, then unref it as before. This preserves the intended regression boundary: launcher shutdown must wait for descendant-held stdio and parse the final buffered frame after the direct ACP parent exits. --- packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index fbac6dfa10..c5a5810068 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -267,6 +267,7 @@ function flushLogsAndExit(): void { `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, ].join(';') spawn(process.execPath, ['-e', code], { + detached: true, stdio: ['ignore', 'inherit', 'inherit'], }).unref() } From f6f984de06dd9542603120681201bbf72bb7a5a2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:21:58 +0800 Subject: [PATCH 37/46] test(tui): cover same-volume cwd outside home The existing /opt footer case reaches the ordinary outside-home return on POSIX, but Windows resolves it on the checkout drive while the user profile is on another drive. That exercises the cross-drive guard instead and leaves the same-volume fallback uncovered in Windows coverage. Add the resolved parent of the home directory as a platform-neutral outside-home path. The case now covers the fallback on every host while retaining /opt to exercise the Windows cross-drive path, restoring per-file branch, statement, and line coverage without platform-specific expectations. --- packages/ui/tui/tests/tui.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4437369728..c13c7a164e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' @@ -361,6 +361,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(unsetResult.terminal.output).toContain('cwd unset') await dispose(unsetResult) + const homeParent = resolve(home, '..') + const parentResult = await setup({ cwd: homeParent }) + expect(parentResult.terminal.output).toContain(homeParent) + await dispose(parentResult) + const outsideResult = await setup({ cwd: '/opt' }) expect(outsideResult.terminal.output).toContain('/opt') await dispose(outsideResult) From 0c1bc50b9830266382f1e4d960cdd531d00c6423 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:30:46 +0800 Subject: [PATCH 38/46] test(code-runtime): widen the idle-budget timing margin The slow-binding test used a 250 ms compute budget, which is below the instrumented worker startup cost seen intermittently in the four-worker Windows coverage job. That startup activity could exhaust the budget before the program settled into the awaited binding, making the timing assertion depend on runner load. Use a one-second compute budget and a two-second binding delay. The awaited wall time still exceeds the busy-time allowance by a clear factor, so the test continues to prove that binding wait time is not charged while leaving enough headroom for worker initialization under coverage. --- .../code-runtime/code-runtime-worker/tests/runtime.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 23c9a8ee60..e95f7cc95b 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -129,10 +129,10 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { }, 15_000) it('does not charge time spent awaiting a slow binding against the compute budget', async () => { - const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 }) + const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 }) const result = await runtime.run({ program: 'return await tools.slow({})', - bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }), + bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 2_000)) }), }) expect(result.error).toBeUndefined() expect(result.value).toBe('slow-done') From 95d803e53d700f18ee761978adac2cb4587e573d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:20:42 +0800 Subject: [PATCH 39/46] test(tui): pin footer-assertion cwd to a fixed path The two transcript tests that assert footer token counters inherited process.cwd() as the session cwd. In a checkout deep enough that the footer label exceeds the 88-column fake terminal, the counters never render and the assertions fail. Pin those tests to a short fixed cwd; cwd rendering keeps its dedicated variants test. --- packages/ui/tui/tests/tui.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c13c7a164e..d27051572e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -147,6 +147,10 @@ describe('TUI config', () => { describe('pi-tui chat lifecycle and transcript', () => { it('renders its header, footer, replay, streaming answer, todos, and status', async () => { const result = await setup({ + // A fixed short cwd keeps the footer's token counters inside the 88-column + // fake terminal regardless of where the checkout lives; cwd rendering has + // its own dedicated variants test below. + cwd: '/workspace', beforeMount(session) { appendUser(session, 'restored prompt') appendAssistant(session, [ @@ -278,6 +282,7 @@ describe('pi-tui chat lifecycle and transcript', () => { it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ + cwd: '/workspace', config: { color: true }, beforeMount(session) { session.append('user/message', { From 5faf8d120096d2193c1c2a2b3663871381b2cddd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:02:06 +0800 Subject: [PATCH 40/46] test(acp-snapshot): skip POSIX-cancel scenarios on Windows The cancel-tool-calls scenario cancels a live bash call, which relies on POSIX detached-process-group termination; bash has no Windows process-tree kill yet (deferred with the Bash execution domain), so the hung call times the scenario out on the native Windows snapshot lane. Add a posixOnly scenario declaration that skips the run test on win32 while the fixture guards keep covering committed files on every platform, and mark cancel-tool-calls with it. --- examples/acp-agent/tests/acp.snapshot.ts | 4 ++- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 31 +++++++++++++++++-- .../support/acp-snapshot/tests/suite.spec.ts | 18 +++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 6c34847295..7ec82d692d 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -111,7 +111,9 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true }, + // Cancelling a live bash call relies on POSIX process-group termination; + // Windows bash process-tree kill is deferred with the Bash execution domain. + { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 53289fda35..062a43a2fc 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -38,7 +38,7 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. -Every scenario compares `stdout.golden.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.golden.windows.jsonl` after the shared golden and requires that sidecar exactly when enabled. +Every scenario compares `stdout.golden.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.golden.windows.jsonl` after the shared golden and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index cd99f3d0c2..850afb1723 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -111,6 +111,32 @@ export interface Scenario { * exactly when the option is set. */ pinsNativeWindowsStdout?: boolean + /** + * Whether the driven behavior needs POSIX process semantics the harness + * cannot exercise on Windows (e.g. cancelling a live bash tool call kills a + * detached process group). The scenario's run test is skipped on Windows; + * its fixtures stay guarded on every platform. + */ + posixOnly?: boolean +} + +/** + * Whether a scenario's run test is skipped for this mode and host: record mode + * skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly} + * scenarios skip on Windows. + * + * @param scenario The scenario whose run test is being registered. + * @param recording Whether the suite runs in record mode. + * @param platform The running Node platform, injectable for unit coverage. + * @returns True when the scenario's run test must not execute. + */ +export function scenarioSkipped( + scenario: Scenario, + recording: boolean, + platform: NodeJS.Platform = process.platform, +): boolean { + if (recording && !scenario.recorded) return true + return scenario.posixOnly === true && platform === 'win32' } /** One stdout golden selected for a platform run. */ @@ -499,8 +525,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. - it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the goldens`, async ({ expect }) => { + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on + // Windows, where their process semantics cannot be driven. + it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the goldens`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index da439378dd..e2ee55f5aa 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -15,6 +15,7 @@ import { normalizedToolSchemas, parseToolSchemasSnapshot, refreshFixtureReplacements, + scenarioSkipped, sessionFixtureNames, restorePinnedToolSchemas, stabilizeRefreshLog, @@ -256,6 +257,23 @@ describe('stdoutGoldenVariants', () => { }) }) +describe('scenarioSkipped', () => { + const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false } + const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true } + + it('skips authored scenarios only while recording', () => { + expect(scenarioSkipped(authored, true, 'linux')).toBe(true) + expect(scenarioSkipped(authored, false, 'linux')).toBe(false) + }) + + it('skips posixOnly scenarios on Windows and nowhere else', () => { + expect(scenarioSkipped(posix, false, 'win32')).toBe(true) + expect(scenarioSkipped(posix, false, 'linux')).toBe(false) + expect(scenarioSkipped(posix, false, 'darwin')).toBe(false) + expect(scenarioSkipped(authored, false, 'win32')).toBe(false) + }) +}) + describe('fixtureContext', () => { it('reads the fixture header id and cwd', () => { const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n') From 37f2c15e6854135d2b17316aaa291b8e3c862b9c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:03:01 +0800 Subject: [PATCH 41/46] fix(fs-sandbox): recognize Windows path aliases --- ...26-07-14-cross-family-fs-sandbox.i18n.yaml | 4 +- .../2026-07-14-cross-family-fs-sandbox.md | 6 +- .../2026-07-14-cross-family-fs-sandbox.zh.md | 6 +- packages/fs/fs-sandbox/README.md | 4 +- packages/fs/fs-sandbox/src/containment.ts | 74 +++++++++++++++++++ packages/fs/fs-sandbox/src/index.ts | 18 ++--- .../fs/fs-sandbox/tests/containment.spec.ts | 56 ++++++++++++++ .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 13 ++-- 8 files changed, 155 insertions(+), 26 deletions(-) create mode 100644 packages/fs/fs-sandbox/src/containment.ts create mode 100644 packages/fs/fs-sandbox/tests/containment.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 8e44e3a6bc..41246ca3b3 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-cross-family-fs-sandbox.md: 9b6312e5994469606bd1645902fc798f70258580 -2026-07-14-cross-family-fs-sandbox.zh.md: d4816e03d94bdf12b2db875d71dccb7db3a2c0d7 +2026-07-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37 +2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index 9b6312e599..0897695cc1 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -31,7 +31,7 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching `packages/fs/fs-sandbox/` (`@deepseek-ai/dsh-fs-sandbox`) mirrors the `bash-local`/`bash-sandbox` split: `SandboxedFileSystem extends LocalFileSystem`, registered as `ctx.fs`, injecting `sandboxPolicy`. Reads (`resolve`/`stat`/`readText`/`streamText`/`listDir`) pass through untouched — every mode permits reading. The two mutations enforce by mode before delegating to the inherited atomic write: - `read-only` denies `writeText`/`editText` outright. -- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Containment is prefix-inclusion on real paths; the target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. +- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` delegates unfenced. A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. @@ -74,7 +74,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the What shipped — the tiers in § Testing hold each: - Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`. -- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, and a new file created under such a symlink — denies every escape on real disks. +- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. - One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. @@ -90,5 +90,5 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index d4816e03d9..15de061a0d 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -31,7 +31,7 @@ Status: implemented `packages/fs/fs-sandbox/`(`@deepseek-ai/dsh-fs-sandbox`)镜像 `bash-local`/`bash-sandbox` 的拆分:`SandboxedFileSystem extends LocalFileSystem`,注册为 `ctx.fs`,注入 `sandboxPolicy`。读取(`resolve`/`stat`/`readText`/`streamText`/`listDir`)原样透传——每种模式都允许读。两个变更操作在委托给继承来的原子写之前按模式执行: - `read-only` 直接拒绝 `writeText`/`editText`。 -- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。包含判定是对真实路径的前缀包含;目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 +- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 - `danger-full-access` 不加围栏地委托。 拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 @@ -74,7 +74,7 @@ Status: implemented 已交付的部分——§ Testing 的各层各自钉住: - 在 `read-only` 下,`write`/`edit` 返回 `[sandbox: file access denied under read-only mode]` 标记,磁盘不受触动;`read`/`listDir` 与 `dsh-fs-local` 行为一致。 -- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录,以及在这样一个符号链接下新建的文件——在真实磁盘上拒绝每一种逃逸。 +- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 - 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 @@ -90,5 +90,5 @@ Status: implemented ## Testing -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、以分隔符结尾的根),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index 685ab838c8..c7043e2e70 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -9,14 +9,14 @@ Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../. The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default: - `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`. -- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. +- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` — delegates unfenced. ## Threat model: a policy fence, not a kernel boundary The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here. -A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md). +A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md). ## Model Experience diff --git a/packages/fs/fs-sandbox/src/containment.ts b/packages/fs/fs-sandbox/src/containment.ts new file mode 100644 index 0000000000..782ceb151d --- /dev/null +++ b/packages/fs/fs-sandbox/src/containment.ts @@ -0,0 +1,74 @@ +/** + * Path-containment mechanics for the filesystem sandbox. Canonical spellings + * take the fast lexical path; filesystem identity supplies the conservative + * fallback for alias-equivalent roots such as Windows 8.3 names and casing. + * @module @deepseek-ai/dsh-fs-sandbox/containment + */ + +import type { BigIntStats } from 'node:fs' +import { stat } from 'node:fs/promises' +import { dirname, sep } from 'node:path' + +function isMissing(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function comparablePath(path: string, caseSensitive: boolean): string { + return caseSensitive ? path : path.toLowerCase() +} + +function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean { + const comparableTarget = comparablePath(path, caseSensitive) + const comparableRoot = comparablePath(root, caseSensitive) + if (comparableTarget === comparableRoot) return true + const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep + return comparableTarget.startsWith(prefix) +} + +async function statIfPresent(path: string): Promise { + try { + return await stat(path, { bigint: true }) + } catch (error: unknown) { + /* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */ + if (isMissing(error)) return undefined + /* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */ + throw error + } +} + +function sameIdentity(left: BigIntStats, right: BigIntStats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** + * Determine whether a canonical target is a writable root or lies beneath it. + * The lexical fast path handles normal canonical spellings. When spellings + * differ, walk the target's existing ancestors and compare filesystem identity + * with the root; this recognizes Windows long-name/8.3 aliases and casing + * without weakening containment to a textual approximation. + * @param path - canonical target key, which may end in a missing suffix. + * @param root - canonical writable root. + * @param caseSensitive - whether lexical comparison preserves case; defaults + * to the host filesystem convention used by supported platforms. + * @returns whether the target is the root or a descendant of it. + */ +export async function isPathUnder( + path: string, + root: string, + caseSensitive = process.platform !== 'win32', +): Promise { + if (isLexicallyUnder(path, root, caseSensitive)) return true + + const rootInfo = await statIfPresent(root) + if (!rootInfo) return false + + let ancestor = path + while (true) { + const ancestorInfo = await statIfPresent(ancestor) + if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true + const parent = dirname(ancestor) + if (parent === ancestor) return false + ancestor = parent + } +} diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 314778968e..5268412955 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -30,7 +30,6 @@ * @module @deepseek-ai/dsh-fs-sandbox */ -import { sep } from 'node:path' import { Context } from 'cordis' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' @@ -39,6 +38,7 @@ import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { isPathUnder } from './containment.ts' /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve @@ -48,13 +48,6 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' */ export type Config = LocalConfig -/** Whether `path` is `root` itself or lies beneath it (both already canonical). */ -function isUnder(path: string, root: string): boolean { - if (path === root) return true - const prefix = root.endsWith(sep) ? root : root + sep - return path.startsWith(prefix) -} - /** * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole @@ -147,7 +140,14 @@ export class SandboxedFileSystem extends LocalFileSystem { // symlink ancestor swapped since the tool resolved this target), and the // mutation delegates with THIS fresh target — never the stale one. const fresh = await this.resolve(target.displayPath) - if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) { + let contained = false + for (const root of this.writableRoots) { + if (await isPathUnder(fresh.targetKey, root)) { + contained = true + break + } + } + if (!contained) { throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED') } return fresh diff --git a/packages/fs/fs-sandbox/tests/containment.spec.ts b/packages/fs/fs-sandbox/tests/containment.spec.ts new file mode 100644 index 0000000000..30821cc826 --- /dev/null +++ b/packages/fs/fs-sandbox/tests/containment.spec.ts @@ -0,0 +1,56 @@ +/** + * Containment tests for lexical canonical paths and filesystem-identity aliases. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import { isPathUnder } from '../src/containment.ts' + +let base: string + +beforeEach(async () => { + base = await mkdtemp(join(tmpdir(), 'dsh-fssbx-containment-')) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('filesystem sandbox containment', () => { + it('accepts equal paths, descendants, and a filesystem-root boundary', async () => { + expect(await isPathUnder(base, base)).toBe(true) + expect(await isPathUnder(join(base, 'child'), base)).toBe(true) + expect(await isPathUnder(base, parse(base).root)).toBe(true) + }) + + it('uses case-insensitive lexical comparison for Windows-style containment', async () => { + expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true) + }) + + it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => { + const realRoot = join(base, 'real') + const aliasRoot = join(base, 'alias') + await mkdir(realRoot) + await symlink(realRoot, aliasRoot) + expect(await isPathUnder(join(await realpath(realRoot), 'missing', 'file.txt'), aliasRoot)).toBe(true) + }) + + it('denies unrelated and missing roots', async () => { + const allowed = join(base, 'allowed') + const outside = join(base, 'outside') + await mkdir(allowed) + await mkdir(outside) + expect(await isPathUnder(join(outside, 'file.txt'), allowed)).toBe(false) + expect(await isPathUnder(join(outside, 'file.txt'), join(base, 'missing-root'))).toBe(false) + }) + + it('treats a regular-file path segment as a missing target, not containment', async () => { + const allowed = join(base, 'allowed') + const blocker = join(base, 'blocker') + await mkdir(allowed) + await writeFile(blocker, 'not a directory') + expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false) + }) +}) diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 12f0abb0df..65472f2ece 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, parse } from 'node:path' import { Context } from 'cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' @@ -167,16 +167,15 @@ describe('workspace-write containment', () => { }) describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => { - it('grants writes anywhere: containment against `/` allows any absolute path', async () => { - // A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's - // separator-suffixed-root branch: `/` already ends in the separator, so the - // prefix stays `/` and every absolute path is contained. + it('grants writes anywhere on that volume', async () => { + // A degenerate but valid config: the filesystem root containing the target. + // It exercises the separator-suffixed-root branch on POSIX and Windows. const rootCtx = new Context() - await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' }) + await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root }) const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace }) const rootFs = rootCtx.fs as SandboxedFileSystem try { - const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root + const path = join(base, 'anywhere.txt') // under HOME, outside temp — allowed only via the filesystem root await rootFs.writeText(await rootFs.resolve(path), 'anywhere') expect(await readFile(path, 'utf8')).toBe('anywhere') } finally { From 23379de6298c66b771a2d69332f534803ad9e133 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:10:53 +0800 Subject: [PATCH 42/46] docs: retire removed stdio paths --- .../simplification/2026-07-04-fold-stdio-ui-helper.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index c44201b4e6..5ed4150883 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -12,7 +12,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +At the time, the helper moved into `@deepseek-ai/dsh-stdio` as its terminal-channel plugin: `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stayed unit-covered under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumed — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` kept proving the composed tree booted through the real Loader (the stdio package's plugin-shape unit suite pinned the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. From 7239513b982201abc1641b3960ad41a85af51bf9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:20:55 +0800 Subject: [PATCH 43/46] test(fs-sandbox): keep Windows coverage portable --- packages/fs/fs-sandbox/src/containment.ts | 4 +++- packages/fs/fs-sandbox/tests/containment.spec.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/fs/fs-sandbox/src/containment.ts b/packages/fs/fs-sandbox/src/containment.ts index 782ceb151d..41b9bdd08a 100644 --- a/packages/fs/fs-sandbox/src/containment.ts +++ b/packages/fs/fs-sandbox/src/containment.ts @@ -9,9 +9,11 @@ import type { BigIntStats } from 'node:fs' import { stat } from 'node:fs/promises' import { dirname, sep } from 'node:path' +const MISSING_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR']) + function isMissing(error: unknown): boolean { const code = (error as NodeJS.ErrnoException).code - return code === 'ENOENT' || code === 'ENOTDIR' + return MISSING_CODES.has(code) } function comparablePath(path: string, caseSensitive: boolean): string { diff --git a/packages/fs/fs-sandbox/tests/containment.spec.ts b/packages/fs/fs-sandbox/tests/containment.spec.ts index 30821cc826..35dc52029b 100644 --- a/packages/fs/fs-sandbox/tests/containment.spec.ts +++ b/packages/fs/fs-sandbox/tests/containment.spec.ts @@ -27,6 +27,7 @@ describe('filesystem sandbox containment', () => { it('uses case-insensitive lexical comparison for Windows-style containment', async () => { expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true) + expect(await isPathUnder(join(base, 'case-sensitive-child'), base, true)).toBe(true) }) it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => { From 16163434233c3849b2bbd970ae92065dcf7be8d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:50:19 +0800 Subject: [PATCH 44/46] test(tui): await validation render --- packages/ui/tui/tests/tui.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 5e38967f42..fa8fed6381 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -819,8 +819,9 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send('x') result.terminal.send(' ') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select at least one option') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Select at least one option') + }) result.terminal.send('c') await tick() result.terminal.send('\x1b') From 92180069ea29acc726baf882d506cefc26dd9bc0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:26:36 +0800 Subject: [PATCH 45/46] test(acp-snapshot): sync Windows goal command transcript --- .../tests/snapshots/workspace-edit/stdout.expected.windows.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl index 7438c3f43f..754b9c5841 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} From f8008b571dbaa10d6b24197faf3a32e09e9c94f5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:47:14 +0800 Subject: [PATCH 46/46] fix(tui): preserve footer truncation after merge --- .../snapshots/workspace-edit/stdout.expected.windows.jsonl | 1 + packages/ui/tui/src/index.ts | 5 +---- packages/ui/tui/tests/tui.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl index 754b9c5841..f39ff91716 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.windows.jsonl @@ -1,6 +1,7 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"A file named greeting.txt in","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 5cb23b31c0..2b16036ada 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -777,10 +777,7 @@ class FooterComponent implements Component { return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`] } const rightAvailable = width - visibleWidth(counters) - 1 - const fullWidth = visibleWidth(formattedCwd) + visibleWidth(counters) + visibleWidth(fullRight) + 3 - const right = this.cwdFormatter === undefined - ? (visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight) - : (fullWidth <= width ? fullRight : compactRight) + const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight const rightClipped = truncateToWidth(right, rightAvailable, '') const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3) const cwd = truncateToWidth(formattedCwd, cwdAvailable, '') diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 796f84b895..7474add504 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -511,10 +511,10 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(outsideResult) const logicalResult = await setup({ - cwd: '/host/worktree', + cwd: '/w', formatCwd: cwd => `logical:${cwd}\x1b`, }) - expect(logicalResult.terminal.output).toContain('logical:/host/worktree\\x1b') + expect(logicalResult.terminal.output).toContain('logical:/w\\x1b') await dispose(logicalResult) })