diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 4be42c59d70..8815f1a635f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -3993,6 +3993,63 @@ describe("sandbox adapter execution targets", () => { } }, 20000); + it("caps the pre-READY buffer under many small newline-less chunks", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-cap-small-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + // An adversarial provider controls the chunk size. It sends many small + // newline-less chunks that together pass the cap. The gate must scan each + // chunk in O(1) of the buffer length, so the pre-READY window stays bounded. + // The gate caps the buffer, finishes with protocol contamination, and falls + // back to the file bridge. + const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; + const smallChunk = "x".repeat(64); + const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; + const { runner, control } = makeDuplexSelectionRunner((ctx) => { + for (let i = 0; i < chunkCount; i += 1) { + ctx.emitRaw(smallChunk); + } + }); + const { recorder, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-cap-small", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + // A long readiness timeout, so the buffer cap, not the timeout, drives the + // failure. + duplexReadinessTimeoutMs: 5_000, + duplexTelemetryRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(1); + // The cap drove the failure, so the file bridge serves after the bounded cleanup. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallback?.dimensions.fallback_reason).toBe("contaminated"); + expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + it("drops a frame header outside the allowlist on the host duplex forward path", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-hdr-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index f9a2749a1be..c26db979c4c 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -2467,7 +2467,15 @@ function createDuplexReadinessGate( // No READY line yet. The gate reads untrusted bytes, so bound the // pre-READY buffer. Past the cap with no newline, the stream cannot be a // valid READY frame, so finish with protocol contamination. - if (Buffer.byteLength(buffer, "utf8") > DUPLEX_READINESS_BUFFER_CAP_BYTES) { + // + // Gate on buffer.length, the UTF-16 code-unit count, which is O(1). + // Buffer.byteLength is O(n), so a byte check on every newline-less chunk + // makes the pre-READY window quadratic in the bytes received. The UTF-8 + // byte length is greater than or equal to the UTF-16 code-unit count for + // every string, so this check never fires before the byte cap is truly + // exceeded. It can fire late by at most a factor of 3, so peak buffer + // memory stays bounded near 3 MB. + if (buffer.length > DUPLEX_READINESS_BUFFER_CAP_BYTES) { finish({ ok: false, reason: "protocol_contamination" }); } return; diff --git a/packages/shared/src/telemetry/README.md b/packages/shared/src/telemetry/README.md index e7f2794752e..159501a8dc4 100644 --- a/packages/shared/src/telemetry/README.md +++ b/packages/shared/src/telemetry/README.md @@ -352,8 +352,12 @@ field. ### Dimension keys -Every span attribute, counter label, and event field uses only these closed keys. -A test asserts the exact set, so a new key never reaches a sink by accident. +Counters carry no dimension labels. The guarded counter store keys each counter +on `(companyId, metric)` with no label column, so the `fallback_reason` and +`loss_class` values fold into the counter metric name instead. The full closed +dimension set below rides only the spans and the `sandbox.duplex.transport` +event, which use only these closed keys. A test asserts the exact set, so a new +key never reaches a sink by accident. | Key | Type | Optional | Value set | | --- | --- | --- | --- |