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 () => {