Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-sdk-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions packages/claude-sdk-adapter/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion packages/claude-sdk-adapter/src/workspace_directories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
11 changes: 10 additions & 1 deletion packages/claude-sdk-adapter/tests/executor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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"}]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
Loading