Skip to content

Commit a5e4f43

Browse files
stubbiclaude
andauthored
fix(sandbox-bridge): faithful body-size limit + timeout/error status mapping (#218)
Two bridge faithfulness fixes so documented agent flows don't fail opaquely: 1. Body-size cap 256KB -> 1MB. Issue documents (plans/specs) allow a 512KB body (upsertIssueDocumentSchema: z.string().max(524288)). A PUT wraps that in a JSON envelope and a GET returns it with metadata, so the old 256KB cap rejected real document reads/writes with an opaque 502. 1MB covers a max document plus envelope; the queue protocol already base64-chunks large bodies. 2. Host-proxy error mapping. A fetch timeout or oversized response previously threw into the worker's generic 502 with a vague message. Now: - AbortSignal timeout -> 504 with a clear "did not respond within 30s; the request may or may not have applied, re-read state before retrying" body. A timeout on a mutating call is ambiguous, and an agent that can't tell a timeout from a failure tends to confabulate an outcome. - Oversized response body -> 413 (call succeeded server-side, payload too large to relay) instead of implying the operation failed. - Other unreachable errors -> 502 with the underlying message. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c1d7fc commit a5e4f43

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

packages/adapter-utils/src/execution-target.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,22 +1173,67 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
11731173
}
11741174
headers.set("authorization", `Bearer ${hostApiToken}`);
11751175
headers.set("x-paperclip-run-id", input.runId);
1176-
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
1177-
method,
1178-
headers,
1179-
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
1180-
signal: AbortSignal.timeout(30_000),
1181-
});
1176+
let response: Response;
1177+
try {
1178+
response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
1179+
method,
1180+
headers,
1181+
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
1182+
signal: AbortSignal.timeout(30_000),
1183+
});
1184+
} catch (error) {
1185+
// Map fetch failures to a faithful status the in-sandbox agent can act
1186+
// on, instead of letting them surface as an opaque generic 502. A
1187+
// timeout in particular is ambiguous on a mutating call ("did my write
1188+
// land?"), so it must be distinguishable — otherwise the agent tends to
1189+
// confabulate an outcome.
1190+
const name = error instanceof Error ? error.name : "";
1191+
if (name === "TimeoutError" || name === "AbortError") {
1192+
return {
1193+
status: 504,
1194+
headers: { "content-type": "application/json" },
1195+
body: JSON.stringify({
1196+
error: "The Paperclip API did not respond within the 30s bridge timeout. The request may or may not have been applied; re-read state before retrying.",
1197+
code: "bridge_upstream_timeout",
1198+
}),
1199+
};
1200+
}
1201+
return {
1202+
status: 502,
1203+
headers: { "content-type": "application/json" },
1204+
body: JSON.stringify({
1205+
error: `Bridge could not reach the Paperclip API: ${error instanceof Error ? error.message : String(error)}`,
1206+
code: "bridge_upstream_unreachable",
1207+
}),
1208+
};
1209+
}
11821210
if (bridgeDebugEnabled) {
11831211
await onLog(
11841212
"stdout",
11851213
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
11861214
);
11871215
}
1216+
let body: string;
1217+
try {
1218+
body = await readBridgeForwardResponseBody(response, maxBodyBytes);
1219+
} catch (error) {
1220+
// Oversized response body: surface a clear 413 (not a generic 502) so
1221+
// the agent knows the call succeeded server-side but the payload was
1222+
// too large to relay, rather than assuming the operation failed.
1223+
return {
1224+
status: 413,
1225+
headers: { "content-type": "application/json" },
1226+
body: JSON.stringify({
1227+
error: error instanceof Error ? error.message : String(error),
1228+
code: "bridge_response_too_large",
1229+
upstreamStatus: response.status,
1230+
}),
1231+
};
1232+
}
11881233
return {
11891234
status: response.status,
11901235
headers: buildBridgeResponseHeaders(response),
1191-
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
1236+
body,
11921237
};
11931238
},
11941239
});

packages/adapter-utils/src/sandbox-callback-bridge.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
1212
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
1313
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
1414
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
15-
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
15+
// Must comfortably exceed the largest legitimate request/response an in-sandbox
16+
// agent exchanges over the bridge. Issue documents (plans/specs) allow a 512KB
17+
// body (upsertIssueDocumentSchema: z.string().max(524288)); a PUT wraps that in
18+
// a JSON envelope and a GET returns it with metadata, so 256KB rejected real
19+
// document reads/writes with an opaque 502. 1MB covers a max document plus its
20+
// JSON envelope with margin; the queue protocol already base64-chunks large bodies.
21+
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 1024 * 1024;
1622
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
1723
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
1824
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";

0 commit comments

Comments
 (0)