{"record":{"id":"a497b723a93beb3c","repo":"JuliusBrussee/caveman","slug":"cave-sandbox-failure-not-serializable","errorCode":"cave_sandbox_failure_not_serializable","errorMessage":"cave_sandbox_failure_not_serializable","messagePattern":"cave_sandbox_failure_not_serializable","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/tool-worker.ts","lineNumber":26,"sourceCode":"import type { AgentDefinition } from \"./index.js\";\nimport { installNetworkDeny } from \"./sandbox-network.js\";\n\n// The result travels on a DEDICATED fd 3, length-prefixed — never stdout\n// Any console.log in the tool's own import graph writes to stdout\n// (fd 1) and the parent ignores it, so it can no longer collide with the result\n// JSON and corrupt it into cave_sandbox_invalid_output.\nlet resultWritten = false;\nfunction writeResult(payload: { ok: boolean; value?: unknown; code?: string }): void {\n  if (resultWritten) return;\n  let encoded: string;\n  try {\n    encoded = JSON.stringify(payload);\n  } catch {\n    if (payload.ok) {\n      writeResult({ ok: false, code: \"cave_sandbox_result_not_serializable\" });\n      return;\n    }\n    throw new Error(\"cave_sandbox_failure_not_serializable\");\n  }\n  // Mark terminal only after serialization succeeds. Otherwise a BigInt or\n  // circular tool value suppresses the failure frame and becomes an opaque\n  // cave_sandbox_invalid_output at the parent.\n  resultWritten = true;\n  const body = Buffer.from(encoded, \"utf8\");\n  const header = Buffer.allocUnsafe(4);\n  header.writeUInt32BE(body.byteLength, 0);\n  const frame = Buffer.concat([header, body]);\n  let offset = 0;\n  while (offset < frame.byteLength) {\n    offset += writeSync(3, frame, offset, frame.byteLength - offset);\n  }\n}\n\nfunction failureCode(error: unknown): string {\n  return error instanceof Error\n    ? [","sourceCodeStart":8,"sourceCodeEnd":44,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/tool-worker.ts#L8-L44","documentation":"Inside the sandboxed tool worker, writeResult serializes the result frame with JSON.stringify. If the FAILURE payload itself cannot be serialized (the failure object's fields — e.g. error message/code — are fine, but a nested value like a BigInt or circular reference sneaks into the payload), and it is not an ok result, the worker throws `cave_sandbox_failure_not_serializable` instead of writing a terminal frame. Successful results get a fallback `cave_sandbox_result_not_serializable` frame; failure payloads have no safe fallback, so this error surfaces.","triggerScenarios":"A sandboxed tool throws an error whose failure-frame payload contains a JSON-incompatible value (BigInt, circular structure, function) so JSON.stringify throws while writing the failure result before resultWritten is set.","commonSituations":"A tool error object carries a BigInt counter or circular `cause` chain; a custom Error subclass attaches non-serializable `resource` metadata; a library rejection embeds Node Buffer-circular internals.","solutions":["Ensure thrown errors in sandboxed tools only carry string fields (message plus optional string `resource`); strip or stringify BigInt/circular values before throwing.","Convert BigInt values with String()/Number() and break circular references before rejecting.","Wrap tool logic so errors are re-thrown as plain `new Error(msg)` without extra properties.","Check whether a recent tool change introduced exotic error metadata and revert it."],"exampleFix":"// before\nthrow Object.assign(new Error(\"quota exceeded\"), { used: 9007199254740993n });\n\n// after\nthrow new Error(`quota exceeded (used=${String(9007199254740993n)})`);","handlingStrategy":"try-catch","validationCode":"function isJsonSafe(v: unknown, seen = new Set()): boolean {\n  if (v === null || typeof v !== \"object\") return typeof v !== \"bigint\";\n  if (seen.has(v)) return false;\n  seen.add(v);\n  return Object.values(v).every((x) => isJsonSafe(x, seen));\n}\n// guard: isJsonSafe(maybeErrorMetadata) before throwing enriched errors","typeGuard":"const isPlainError = (e: unknown): e is Error =>\n  e instanceof Error &&\n  Object.getOwnPropertyNames(e).every((k) => k === \"message\" || typeof (e as any)[k] === \"string\");","tryCatchPattern":"try {\n  await runSandboxedTool(args);\n} catch (err) {\n  // re-throw as a plain serializable error so the worker failure frame can be written\n  throw new Error(err instanceof Error ? err.message : String(err));\n}","preventionTips":["Throw only plain Error objects with string message (and optional string `resource`) from sandboxed tools.","Never attach BigInt, functions, or circular structures to error objects crossing the sandbox boundary.","Stringify any large/numeric-exotic values into the message text instead of extra properties.","Add a unit test asserting every error path in your tool is JSON.stringify-safe."],"tags":["sandbox","serialization","json","worker"],"backgroundTag":"json-serialization-failed","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}