diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index b0143e694..92fdb4b7d 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -155,6 +155,12 @@ func TestLiveClaudeWorkspaceFactory(t *testing.T) { t.Fatal("cancellation outcome differs from terminal Done") } } + // Native history settles when the executor closes after Done; the next step must not race that close. + select { + case <-s.process.Done(): + case <-ctx.Done(): + t.Fatal("native process did not exit after the Turn") + } writeEvidence(name, proof) if proof.Terminals != 1 || (cancelOnEffect && !proof.Cancelled) { t.Fatal("missing actual cancellation or unique completion") diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 7ec44761d..d06a16da1 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -42,7 +42,7 @@ Each Turn ends with a result/error and `turn_settled`, independently of process The optional private `agent.WorkspaceReader` on this Executor and its delegated wrappers requires the packaged `workspace_read` feature. It sends bounded relative paths to that same SDK Query's native `readFile` control. Only the adapter combines the path with the frozen workspace root; callers cannot replace the placement. The pinned native read handler awaits file-handle close before its successful base64 response. The adapter validates bytes and truncation, bounds each result to 1 MiB and each request to 8 KiB, and admits one read at a time. Its continuous bridge output consumer retains read receipts during preparation and across Turns. Caller cancellation detaches observation without cancelling the Run or discarding an admitted waiter; its original deadline still applies. Owner closure stops admission. Native null, malformed receipts, timeout and interrupted delivery remain uncertain and stop the owner; local reap is not a successful read settlement. The SDK's nullable result catches all native/control errors, so it cannot distinguish missing files from denial or transport failure. The reader provides no public Files endpoint, snapshot consistency, placement registration or idle owner policy. The qualified live workspace fixture also checks binary, empty and bounded reads before input and during real execution, plus effects before cancellation and reads on fresh-process history continuation. -The optional private `agent.WorkspaceDirectoryLister` uses the portable `workspace_directory` bridge feature. Node reads directory metadata under the selected workspace; there is no Linux `/proc/self/fd` dependency. Public daemon Files operations share the Go Binding implementation on all platforms. Their relative path and result bounds define the Files API, not Harness permissions. +The optional private `agent.WorkspaceDirectoryLister` uses the portable `workspace_directory` bridge feature. Node reads directory metadata under the selected workspace and never follows a link ([list files](../../contracts/agents-api/environment-files.md#list-files)); there is no Linux `/proc/self/fd` dependency. Public daemon Files operations share the Go Binding implementation on all platforms. Their relative path and result bounds define the Files API, not Harness permissions. Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit truncation, literal names, kinds, and sizes only for regular files. They do not promise ordering, snapshots, recursion, or public pagination. Missing and permission errors are returned only from distinguishable filesystem outcomes; unknown results stop the owner. Each operation closes its directory before a successful receipt. Caller cancellation, Turn transitions and owner shutdown follow the workspace read settlement rules. The lister alone does not enable public Claude Files; the dedicated Runtime integration supplies public placement and ownership. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 6a9115706..0a710f68a 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -59,13 +59,14 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if (request.require_history && !request.resume) { const recovered = await recoverSession(request.cwd); if (!recovered) { - await emit({ type: "error", code: "history_unavailable" }); + // No Turn is bound yet, so the Executor owner reports missing history. + await ownerEmit({ type: "error", code: "history_unavailable" }); return; } request = { ...request, resume: recovered }; } if (request.resume && !await getSessionInfo(request.resume, { dir: request.cwd })) { - await emit({ type: "error", code: "history_unavailable" }); + await ownerEmit({ type: "error", code: "history_unavailable" }); return; } subagents?.expectSession(request.resume); diff --git a/packages/claude-sdk-adapter/src/workspace_directories.ts b/packages/claude-sdk-adapter/src/workspace_directories.ts index 355bf0c0a..a55c12486 100644 --- a/packages/claude-sdk-adapter/src/workspace_directories.ts +++ b/packages/claude-sdk-adapter/src/workspace_directories.ts @@ -46,7 +46,12 @@ export class WorkspaceDirectories { } private async enumerate(directory: string, maxEntries: number): Promise<{ entries: WorkspaceDirectoryEntry[]; truncated: boolean }> { - const path = join(this.root!, ...directory.split("/")); + let path = this.root!; + // Listings never follow a link: one anywhere in the path is rejected like a regular file. + for (const part of directory ? directory.split("/") : []) { + path = join(path, part); + if (!(await lstat(path)).isDirectory()) throw Object.assign(new Error("not a directory"), { code: "ENOTDIR" }); + } const dir = await opendir(path, { encoding: "buffer" as BufferEncoding }); try { const entries: WorkspaceDirectoryEntry[] = []; diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index d30b7505d..dfb997257 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { registerHooks } from "node:module"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -const sdk = "export async function startup(args){return globalThis.startupFixture(args)} export function query(){throw new Error('one warm query required')} export async function getSessionInfo(){return {sessionId:'native'}}"; +const sdk = "export async function startup(args){return globalThis.startupFixture(args)} export function query(){throw new Error('one warm query required')} export async function getSessionInfo(){return process.argv[1]==='missing-history' ? undefined : {sessionId:'native'}}"; registerHooks({resolve(specifier,context,next){if(specifier==='@anthropic-ai/claude-agent-sdk') return {url:'data:text/javascript,'+encodeURIComponent(sdk),shortCircuit:true};return next(specifier,context)}}); globalThis.startupFixture=async({options})=>{ const child=options.spawnClaudeCodeProcess({command:process.execPath,args:['-e',"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))"],env:options.env,signal:options.abortController.signal}); @@ -193,3 +193,12 @@ for (const mode of ["pending-function-result", "pending-function-terminal", "pen } assert.deepEqual(await closed,{code:0,signal:null}); }); + +test("executor preparation reports missing native history before readiness",{timeout:10000},async()=>{ + const child=spawn(process.execPath,["--input-type=module","-e",fixture,"missing-history"],{stdio:["pipe","pipe","pipe","ipc"]}); + let stdout="";child.stdout.setEncoding("utf8");child.stdout.on("data",data=>{stdout+=data}); + const closed=new Promise(resolve=>child.once("close",code=>resolve(code))); + child.stdin.end(JSON.stringify({type:"executor_prepare",cwd:"/tmp",model:"fixture",system_prompt:"",resume:"native"})+"\n"); + assert.equal(await closed,0); + assert.deepEqual(stdout.trim().split("\n").map(line=>JSON.parse(line)),[{type:"error",code:"history_unavailable"}]); +}); diff --git a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs index d9c3ae1bb..dd3abf7a1 100644 --- a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs @@ -41,7 +41,7 @@ test("bounded literal metadata, missing directory, and traversal denial", async assert.deepEqual((await f.list("nested")).entries, []); assert.equal((await f.list("", 1)).truncated, true); assert.equal((await f.list("absent")).error, "not_found"); - assert.deepEqual((await f.list("inside")).entries, []); + for (const path of ["inside", "outside", "outside/workspace", "binary"]) assert.equal((await f.list(path)).error, "invalid"); for (const path of ["/", "a//b", ".", "..", "a/../b", "a\\b", "a\n"]) assert.equal((await f.list(path)).error, "invalid"); for (const limit of [0, -1, 1001, 1.1]) assert.equal((await f.list("", limit)).error, "invalid"); });