Skip to content
Open
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 @@ -18,9 +18,11 @@
* rather than a JSON value, costing the Inspector its pretty-printing and
* the round-trip contract test; the accurate sentence is the better trade.
*
* Total by construction. Every write is wrapped: a failure disables the sink
* for the process lifetime, reports once, and is never propagated to the
* caller. The sink sits on the decision path: an observability feature that
* Total by construction. Every write is wrapped: a filesystem failure disables
* the sink for the process lifetime, reports once, and is never propagated to
* the caller. A value that cannot be serialized is not a filesystem failure:
* it is logged as a `serialization_failed` marker in place of the envelope,
* `write` answers false for it, and the sink stays on for the next request. The sink sits on the decision path: an observability feature that
* can turn a governed tool call into an ungoverned one is the one failure
* mode this module must never have. Observability degrades; governance does
* not.
Expand Down Expand Up @@ -141,10 +143,22 @@ export function createEnvelopeLogSink({
rpc_id: rpcId === undefined ? extractRpcId(envelope) : rpcId,
envelope,
};
const line = `${JSON.stringify(entry)}\n`;
let line: string;
let recorded = true;
try {
line = `${JSON.stringify(entry)}\n`;
} catch (error) {
// A value JSON.stringify cannot serialize (a 400,000-deep array
// overflows the stack, a cycle or BigInt throws) costs this one
// record, not the sink: log a marker in its place and stay on.
// Only a filesystem failure below disables the sink.
const reason = error instanceof Error ? error.message : String(error);
line = `${JSON.stringify({ ...entry, envelope: { serialization_failed: true, reason } })}\n`;
recorded = false;
}
appendFileSync(path, line);
seq += 1;
return true;
return recorded;
} catch (error) {
fail(error);
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ describe("Guardian envelope log wiring", () => {
}
});

// One request whose params nest 400,000 arrays overflows JSON.stringify in
// the sink. It must cost that one record, not every record after it.
it("keeps logging after a request the sink cannot serialize", async () => {
await withGuardian(logIn, async (url, logPath) => {
const deepBody = `{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":1,"params":${"[".repeat(400_000)}${"]".repeat(400_000)}}`;
await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: deepBody }).catch(() => undefined);
const before = readEntries(logPath).length;

await postRaw(url, JSON.stringify(toolCallEnvelope("ls -la", { id: 21 })));
await postRaw(url, JSON.stringify(toolCallEnvelope("ls -la", { id: 22 })));

const entries = readEntries(logPath);
expect(entries.length).toBe(before + 4);
expect(entries.slice(-4).map((e) => e.rpc_id)).toEqual([21, 21, 22, 22]);
});
});

it("writes nothing when envelopeLogPath is omitted", async () => {
const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-off-"));
const logPath = join(dir, "envelopes.jsonl");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("createEnvelopeLogSink -- the envelope log's JSONL format", () => {
});
});

it("never throws on an envelope JSON.stringify cannot serialize", () => {
it("never throws on an envelope JSON.stringify cannot serialize, and keeps the sink on", () => {
withTempDir((dir) => {
const path = join(dir, "envelopes.jsonl");
const errors: unknown[] = [];
Expand All @@ -125,7 +125,8 @@ describe("createEnvelopeLogSink -- the envelope log's JSONL format", () => {
circular.self = circular;

expect(() => sink.write("request", circular, "steps/toolCallRequest")).not.toThrow();
expect(errors.length).toBe(1);
expect(errors.length).toBe(0);
expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true);
});
});

Expand Down Expand Up @@ -214,3 +215,48 @@ describe("createEnvelopeLogSink -- write says whether the line landed", () => {
expect(NULL_ENVELOPE_LOG_SINK.write("finding", {}, "steps/toolCallResult")).toBe(false);
});
});

describe("createEnvelopeLogSink -- a value that cannot be serialized", () => {
function nested(depth: number): unknown {
let value: unknown = [];
for (let i = 0; i < depth; i += 1) {
value = [value];
}
return value;
}

it("costs that one record, logs a failure marker, and leaves the sink on", () => {
withTempDir((dir) => {
const path = join(dir, "envelopes.jsonl");
const errors: unknown[] = [];
const sink = createEnvelopeLogSink({ path, onError: (e) => errors.push(e) });

const deep = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 5, params: nested(400_000) };
expect(sink.write("request", deep, "steps/toolCallRequest")).toBe(false);
expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true);
expect(sink.write("response", RESPONSE, "steps/toolCallRequest")).toBe(true);

expect(errors).toEqual([]);
const entries = readEntries(path);
expect(entries.map((e) => e.seq)).toEqual([1, 2, 3]);
expect(entries[0]?.rpc_id).toBe(5);
expect(entries[0]?.envelope).toMatchObject({ serialization_failed: true });
expect(entries[1]?.envelope).toEqual(REQUEST);
});
});

it("treats a cycle or a BigInt the same way", () => {
withTempDir((dir) => {
const path = join(dir, "envelopes.jsonl");
const sink = createEnvelopeLogSink({ path, onError: () => {} });
const cyclic: Record<string, unknown> = { id: 1 };
cyclic.self = cyclic;

expect(sink.write("request", cyclic, "steps/toolCallRequest")).toBe(false);
expect(sink.write("request", { id: 2, n: 1n }, "steps/toolCallRequest")).toBe(false);
expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true);

expect(readEntries(path).map((e) => e.seq)).toEqual([1, 2, 3]);
});
});
});
Loading