Merge master into Runner D delivery

Resolve the App delivery conflicts while preserving native session recovery and current login/duplex behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-23 20:38:03 +00:00
commit 2c5c2f0fce
93 files changed
+11860 -1628

No files matched your search

@@ -5763,8 +5763,8 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () =>
// Wrap a started real broker in a paperclip bridge handle. The handle exposes
// the same run-disposition surface the sandbox bridge exposes, so the seam runs
// against the real latch and the real orderly-completion mark.
function bridgeOverBroker(fake: ReturnType<typeof createFakeDuplexChannel>) {
const broker = createDuplexBridgeBroker({
async function bridgeOverBroker(fake: ReturnType<typeof createFakeDuplexChannel>) {
const broker = await createDuplexBridgeBroker({
channel: fake.channel,
forwardRequest: async () => ({ status: 200 }),
});
@@ -5891,7 +5891,7 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () =>
it("fails a completed run when the duplex channel was lost before the completion", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeDuplexChannel();
const { broker, handle, settleRunDisposition } = bridgeOverBroker(fake);
const { broker, handle, settleRunDisposition } = await bridgeOverBroker(fake);
// Latch the loss before the ACP terminal resolves.
const runtime = runtimeWithControlledResult(() => fake.emitExit({ exitCode: 1 }));
@@ -5912,7 +5912,7 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () =>
it("keeps a completed run a success when the channel stays live, and a later teardown loss is benign", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeDuplexChannel();
const { broker, handle, settleRunDisposition } = bridgeOverBroker(fake);
const { broker, handle, settleRunDisposition } = await bridgeOverBroker(fake);
// No loss before the completion.
const runtime = runtimeWithControlledResult();
@@ -5932,7 +5932,7 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () =>
it("does not let a later completion or activity clear the loss latch", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeDuplexChannel();
const { broker, handle } = bridgeOverBroker(fake);
const { broker, handle } = await bridgeOverBroker(fake);
// Latch the loss before the ACP terminal resolves.
const runtime = runtimeWithControlledResult(() => fake.emitExit({ exitCode: 1 }));
@@ -5951,7 +5951,7 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () =>
it("marks an orderly completion on a failed terminal so the teardown loss emits no false loss", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeDuplexChannel();
const { broker, handle, markOrderlyCompletion } = bridgeOverBroker(fake);
const { broker, handle, markOrderlyCompletion } = await bridgeOverBroker(fake);
// The turn fails, and no channel loss ordered before the finalization.
const runtime = runtimeWithFailedResult();
@@ -40,8 +40,13 @@ export interface CommandManagedDuplexChannel {
write(data: string): void;
/** Registers the one data listener. The channel streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Registers the one exit listener. The channel calls it one time with the exit. */
onExit(listener: (exit: { exitCode: number | null }) => void): void;
/**
* Registers the one exit listener. The channel calls it one time with the exit.
* A numeric `exitCode` is a real process exit. `transportClosed` is true when the
* provider transport closed with no exit data, so a reader can tell a real
* process exit from a reason-less transport close.
*/
onExit(listener: (exit: { exitCode: number | null; transportClosed?: boolean }) => void): void;
/** Stops the child process. Safe to call more than one time. */
stop(): void;
/** Closes the channel and releases the route. Safe to call more than one time. */
@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
DUPLEX_AGGREGATE_TOKEN_OWNERS,
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX,
assertDuplexAggregateCeilingBytes,
resolveDuplexAggregateCeilingBytes,
type DuplexAggregateByteLedgerTelemetry,
} from "./duplex-aggregate-byte-ledger.js";
// A telemetry surface that counts each fixed record, so a test asserts the exact
// gauge value and the exact counter counts.
function createCountingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
rejections: number;
underflows: number;
} {
const state = { gauge: 0, rejections: 0, underflows: 0 };
return {
...state,
setBytesInUse(bytes: number) {
this.gauge = bytes;
},
recordReservationRejection() {
this.rejections += 1;
},
recordAccountingUnderflow() {
this.underflows += 1;
},
};
}
describe("DuplexAggregateByteLedger", () => {
it("reserves under the ceiling and tracks the gauge and the live-token count", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const a = ledger.reserve("buffered_chunk", 400);
const b = ledger.reserve("buffered_chunk", 500);
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(ledger.bytesInUse).toBe(900);
expect(ledger.liveTokenCount).toBe(2);
expect(telemetry.gauge).toBe(900);
expect(a?.bytes).toBe(400);
expect(a?.owner).toBe("buffered_chunk");
expect(a?.state).toBe("held");
});
it("rejects a one-byte-over reservation, retains nothing, and increments the rejection counter", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
ledger.reserve("buffered_chunk", 1000);
const over = ledger.reserve("buffered_chunk", 1);
expect(over).toBeNull();
expect(ledger.bytesInUse).toBe(1000);
expect(ledger.liveTokenCount).toBe(1);
expect(telemetry.rejections).toBe(1);
// The public rejection marker is the fixed string the caller reports.
expect(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED).toBe("DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED");
});
it("transfers ownership without a decrement or a re-reserve, so no admission gap opens", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
// Reserve the whole ceiling as a pre-bind event, then transfer to buffered.
const token = ledger.reserve("pre_bind_event", 100);
expect(token).not.toBeNull();
// A second reservation cannot fit while the token stays held.
expect(ledger.reserve("buffered_chunk", 1)).toBeNull();
ledger.transfer(token!, "buffered_chunk");
// The transfer changed only the owner. The gauge never moved, so no window
// opened where the bytes were released and re-admitted.
expect(ledger.bytesInUse).toBe(100);
expect(ledger.liveTokenCount).toBe(1);
expect(token?.owner).toBe("buffered_chunk");
expect(telemetry.gauge).toBe(100);
// No reservation fit during the transfer, so no rejection or underflow fired
// beyond the one rejection above.
expect(telemetry.rejections).toBe(1);
expect(telemetry.underflows).toBe(0);
});
it("releases a token one time and decrements the gauge", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("response_body", 250);
expect(token).not.toBeNull();
ledger.release(token!);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(token?.state).toBe("released");
expect(telemetry.gauge).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("records the underflow counter on a second release and does not clamp the gauge again", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const kept = ledger.reserve("request_frame", 300);
const doubled = ledger.reserve("request_frame", 200);
expect(kept).not.toBeNull();
expect(doubled).not.toBeNull();
ledger.release(doubled!);
expect(ledger.bytesInUse).toBe(300);
// A forced second release of the same token is an accounting defect.
ledger.release(doubled!);
expect(telemetry.underflows).toBe(1);
// The gauge did not drop a second time, so the defect stays visible.
expect(ledger.bytesInUse).toBe(300);
expect(ledger.liveTokenCount).toBe(1);
});
it("records the underflow counter on a transfer of a released token", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("pre_bind_event", 100);
ledger.release(token!);
ledger.transfer(token!, "buffered_chunk");
expect(telemetry.underflows).toBe(1);
expect(ledger.bytesInUse).toBe(0);
});
it("throws on a non-integer or negative reservation, so a programming error fails loud", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
expect(() => ledger.reserve("buffered_chunk", -1)).toThrow();
expect(() => ledger.reserve("buffered_chunk", 1.5)).toThrow();
expect(() => ledger.reserve("buffered_chunk", Number.NaN)).toThrow();
});
it("admits a zero-byte reservation as a valid held token", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
const token = ledger.reserve("seen_request_id", 0);
expect(token).not.toBeNull();
expect(token?.bytes).toBe(0);
expect(ledger.liveTokenCount).toBe(1);
});
it("exposes the closed owner-label set", () => {
expect([...DUPLEX_AGGREGATE_TOKEN_OWNERS]).toEqual([
"pre_bind_event",
"buffered_chunk",
"terminal_buffered",
"request_frame",
"request_payload",
"response_body",
"seen_request_id",
"decoder_buffer",
"readiness_buffer",
"readiness_replay",
"pending_write",
"stdin_write",
]);
});
});
describe("aggregate ceiling configuration", () => {
it("uses the default when the override is absent", () => {
expect(resolveDuplexAggregateCeilingBytes()).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(resolveDuplexAggregateCeilingBytes(null)).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES).toBe(256 * 1024 * 1024);
});
it("accepts a valid positive override under the process allocation minus the reserve", () => {
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024)).toBe(64 * 1024 * 1024);
const maxCeiling =
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX - DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
expect(resolveDuplexAggregateCeilingBytes(maxCeiling)).toBe(maxCeiling);
});
it("rejects an invalid override to the safe default and reports each rejection", () => {
const overMaximum = MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX;
const invalidOverrides = [0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN, overMaximum];
for (const invalid of invalidOverrides) {
const rejected: number[] = [];
// An invalid present override returns the safe default. It never throws, so a
// single bad environment value never fails host startup.
expect(resolveDuplexAggregateCeilingBytes(invalid, (value) => rejected.push(value))).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
// The resolver reports the rejection one time with the rejected numeric value.
expect(rejected).toEqual([invalid]);
}
});
it("does not report a rejection for a valid or a missing override", () => {
const rejected: number[] = [];
const reporter = (value: number): void => {
rejected.push(value);
};
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024, reporter)).toBe(64 * 1024 * 1024);
expect(resolveDuplexAggregateCeilingBytes(undefined, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(resolveDuplexAggregateCeilingBytes(null, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(rejected).toEqual([]);
});
it("still throws for a direct ceiling assertion on an unsafe integer", () => {
// The ledger constructor asserts its ceiling and must fail loud on a
// programming error, so `assertDuplexAggregateCeilingBytes` keeps throwing.
expect(() => assertDuplexAggregateCeilingBytes(Number.MAX_SAFE_INTEGER + 2)).toThrow();
});
});
@@ -0,0 +1,400 @@
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel.
*
* The route-count controller bounds how many concurrent duplex routes stay live.
* It does not bound the aggregate bytes those routes retain. At the maximum route
* count the per-route byte bounds multiply to many gigabytes of retained bytes.
* This ledger closes that gap. One ledger per host server process owns a single
* byte ceiling. Every host-side retention site reserves its exact retained bytes
* against this ledger before it allocates, so the aggregate retained bytes across
* all live routes never pass the ceiling.
*
* The ledger is fail-closed. A reservation that would pass the ceiling returns no
* token. The caller retains nothing and reports the fixed rejection marker
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}.
*
* Ownership is transfer-only. A token holds an immutable byte amount, an owner
* label from a closed enum, and a one-way state: `held` then `released`. The
* ledger moves a token from one representation to the next with
* {@link DuplexAggregateByteLedger.transfer}, which changes only the owner label.
* It never decrements and re-reserves, so no admission gap opens between two
* representations of the same retained bytes.
*
* The ledger holds every live token in one owner registry. Registry removal and
* the release of a token run in one synchronous critical section, before any
* `await` or external callback. A second release of the same token is an
* accounting defect. The ledger records the fixed underflow counter and does not
* clamp the gauge down a second time, so a real defect stays visible.
*
* The ledger carries no payload data and no arbitrary labels. It exposes only the
* byte gauge, the live-token count, and the closed owner enum.
*/
import {
DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL,
DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL,
DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE,
} from "./duplex-telemetry.js";
/**
* The public rejection marker. A retention site that cannot reserve its bytes
* reports this fixed string. It carries no payload, no route, and no raw value.
*/
export const DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED = "DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED";
/**
* The default aggregate ceiling, in bytes: 256 MiB. This is one eighth of the
* documented minimum host process memory allocation for the sandbox duplex
* transport ({@link MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX}, 2 GiB). The
* remaining seven eighths cover the Node runtime, ordinary server work, parser
* and transient headroom, and allocator or garbage-collector variance.
*/
export const DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES = 256 * 1024 * 1024;
/**
* The documented minimum host process memory allocation to enable the sandbox
* duplex transport, in bytes: 2 GiB. An operator override of the aggregate
* ceiling must not pass this value minus the documented reserve.
*/
export const MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX = 2 * 1024 * 1024 * 1024;
/**
* The closed set of owner labels a reservation token can carry. Each label names
* one host-side representation that retains bytes. The ledger accepts no label
* outside this set, so no arbitrary label ever reaches a token.
*
* - `pre_bind_event`: a held pre-bind duplex event, before route bind.
* - `buffered_chunk`: a buffered data chunk retained for a late listener.
* - `terminal_buffered`: a buffered chunk moved to the terminal registry at
* route terminalization, held for a late listener drain.
* - `request_frame`: a bounded raw request frame the broker retains at dispatch.
* - `request_payload`: a normalized request payload the broker retains at dispatch.
* - `response_body`: a response-body chunk or replacement buffer the reader retains.
* - `seen_request_id`: one entry of the broker no-replay request-id set.
* - `decoder_buffer`: the raw partial-frame bytes the host frame decoder retains
* between chunks, plus the peak replacement buffer it allocates on concat.
* - `readiness_buffer`: the raw untrusted bytes the readiness gate retains before
* the READY frame completes, before the broker binds and the decoder takes over.
* - `readiness_replay`: the post-READY suffix bytes the readiness gate holds in the
* pending replay buffer, from the READY accept until the broker binds and the
* frame decoder charges its own retention. This is a separate retention from
* `readiness_buffer`: the gate drops the whole pre-READY buffer on READY, then
* charges only the retained suffix and each later pre-bind chunk under this owner.
* - `pending_write`: the raw host-to-worker write payload a pending duplex write
* RPC retains, from the enqueue seam until the RPC settles.
* - `stdin_write`: the serialized host-to-worker frame the child-stdin transport
* buffer retains, from the write until the stream flushes the chunk, the stream
* errors, the stream closes, or the worker exits. This is a separate retention
* from `pending_write`: the RPC holds the raw payload while the transport buffer
* holds the larger serialized frame, so the two tokens cover the peak of both.
*/
export const DUPLEX_AGGREGATE_TOKEN_OWNERS = [
"pre_bind_event",
"buffered_chunk",
"terminal_buffered",
"request_frame",
"request_payload",
"response_body",
"seen_request_id",
"decoder_buffer",
"readiness_buffer",
"readiness_replay",
"pending_write",
"stdin_write",
] as const;
/** One owner label from the closed {@link DUPLEX_AGGREGATE_TOKEN_OWNERS} set. */
export type DuplexAggregateTokenOwner = (typeof DUPLEX_AGGREGATE_TOKEN_OWNERS)[number];
/** The one-way lifecycle state of a reservation token. */
export type DuplexReservationTokenState = "held" | "released";
/**
* An opaque reservation token. The byte amount is immutable. The owner label
* moves through the closed enum by transfer only. The state moves one way, from
* `held` to `released`. A caller reads these fields but never mutates them; only
* the owning ledger changes the owner and the state.
*/
export interface ReservationToken {
/** The immutable exact byte amount this token reserves. */
readonly bytes: number;
/** The current owner label. The ledger changes it only through `transfer`. */
readonly owner: DuplexAggregateTokenOwner;
/** The one-way lifecycle state. The ledger sets it to `released` one time. */
readonly state: DuplexReservationTokenState;
}
/**
* The internal mutable token. The ledger owns it and returns it typed as the
* read-only {@link ReservationToken}. Only code in this module changes the owner
* or the state.
*/
class ReservationTokenImpl implements ReservationToken {
readonly bytes: number;
owner: DuplexAggregateTokenOwner;
state: DuplexReservationTokenState = "held";
constructor(bytes: number, owner: DuplexAggregateTokenOwner) {
this.bytes = bytes;
this.owner = owner;
}
}
/**
* The fixed telemetry surface for the aggregate byte ledger. The host binds it to
* the closed duplex telemetry contract. Each record uses only fixed constant
* dimensions. No company, agent, run, route, URL, header, request id, token, byte
* payload, or raw provider value ever reaches a record. The default is a no-op.
*/
export interface DuplexAggregateByteLedgerTelemetry {
/** Set the aggregate-bytes-in-use gauge to the current value. */
setBytesInUse(bytes: number): void;
/** Increment the reservation-rejection counter one time. */
recordReservationRejection(): void;
/** Increment the accounting-underflow counter one time. */
recordAccountingUnderflow(): void;
}
/** A no-op telemetry surface. Every method does nothing, so the ledger stays inert. */
export const NOOP_DUPLEX_AGGREGATE_BYTE_LEDGER_TELEMETRY: DuplexAggregateByteLedgerTelemetry = {
setBytesInUse() {},
recordReservationRejection() {},
recordAccountingUnderflow() {},
};
/**
* The low-level process metric sink the host binds. It carries the fixed metric
* name and the value only, so no dynamic dimension reaches a sink. The host maps
* the gauge and the two counters to the process metric pipeline and to a warn log
* for a defect.
*/
export interface DuplexAggregateByteLedgerMetricSink {
/** Set a named process gauge to a value. */
setGauge(name: string, value: number): void;
/** Increment a named process counter one time. */
incrementCounter(name: string): void;
}
/**
* Build a {@link DuplexAggregateByteLedgerTelemetry} that maps each ledger event
* to the fixed metric name on the process metric sink. The gauge uses
* {@link DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE}. The rejection counter uses
* {@link DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL}. The defect
* counter uses {@link DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL}.
* Each call sits in an error swallow, so a sink failure never breaks the reserve
* or the release path.
*/
export function createDuplexAggregateByteLedgerTelemetry(
sink: DuplexAggregateByteLedgerMetricSink,
): DuplexAggregateByteLedgerTelemetry {
return {
setBytesInUse(bytes: number): void {
try {
sink.setGauge(DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE, bytes);
} catch {
// A telemetry failure never breaks the reserve or the release path.
}
},
recordReservationRejection(): void {
try {
sink.incrementCounter(DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL);
} catch {
// A telemetry failure never breaks the reserve path.
}
},
recordAccountingUnderflow(): void {
try {
sink.incrementCounter(DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL);
} catch {
// A telemetry failure never breaks the release path.
}
},
};
}
/** The options for {@link DuplexAggregateByteLedger}. */
export interface DuplexAggregateByteLedgerOptions {
/** The aggregate ceiling, in bytes. It must be a positive safe integer. */
ceilingBytes: number;
/** The fixed telemetry surface. The default is the no-op surface. */
telemetry?: DuplexAggregateByteLedgerTelemetry;
}
/**
* Assert an aggregate ceiling byte value. The value must be a positive safe
* integer that is no larger than the documented process allocation minus the
* documented reserve. The function throws on any other value, so an invalid
* ceiling fails startup instead of silently disabling the ledger.
*
* The reserve equals the documented process allocation minus the default
* ceiling ({@link DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES}), so the maximum
* accepted ceiling is exactly the documented process allocation minus that
* reserve.
*/
export function assertDuplexAggregateCeilingBytes(value: number): void {
const maxCeiling = maxDuplexAggregateCeilingBytes();
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(
`The duplex aggregate byte ceiling must be a positive safe integer; got ${String(value)}.`,
);
}
if (value > maxCeiling) {
throw new Error(
`The duplex aggregate byte ceiling ${value} must not pass the documented process allocation minus the reserve (${maxCeiling}).`,
);
}
}
/**
* The maximum accepted aggregate ceiling, in bytes: the documented process
* allocation minus the documented reserve. Both {@link assertDuplexAggregateCeilingBytes}
* and {@link isValidDuplexAggregateCeilingBytes} read this one bound.
*/
function maxDuplexAggregateCeilingBytes(): number {
return MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX - DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
/**
* Report whether `value` is a valid aggregate ceiling. A valid ceiling is a
* positive safe integer that is no larger than {@link maxDuplexAggregateCeilingBytes}.
* This is the non-throwing form of {@link assertDuplexAggregateCeilingBytes}. The
* override resolver uses it to reject an invalid override without a throw.
*/
export function isValidDuplexAggregateCeilingBytes(value: number): boolean {
return Number.isSafeInteger(value) && value >= 1 && value <= maxDuplexAggregateCeilingBytes();
}
/**
* A reporter the host binds to receive one invalid-override rejection signal.
* {@link resolveDuplexAggregateCeilingBytes} calls it one time when it rejects a
* present invalid override and falls back to the safe default. The signal carries
* only the rejected numeric value. It carries no route, company, run, or payload
* value.
*/
export type DuplexAggregateCeilingOverrideRejectionReporter = (rejectedValue: number) => void;
/**
* Resolve an aggregate ceiling from an optional operator override. A missing or
* `null` override uses {@link DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES}. A present
* valid value passes through unchanged.
*
* A present invalid override does not fail host startup. The host process is
* multi-tenant, so one invalid environment value must not brick the whole host.
* The resolver rejects the invalid override, reports it through the optional
* `onRejectedOverride` reporter, and returns the conservative safe default. This
* fails loud without failing closed on availability.
*/
export function resolveDuplexAggregateCeilingBytes(
override?: number | null,
onRejectedOverride?: DuplexAggregateCeilingOverrideRejectionReporter,
): number {
if (override === undefined || override === null) {
return DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
if (!isValidDuplexAggregateCeilingBytes(override)) {
onRejectedOverride?.(override);
return DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
return override;
}
/**
* The process-owned aggregate byte ledger. Create one per host server process.
* Inject the same object into every host-side retention site, so one shared gauge
* bounds the aggregate retained bytes across all live duplex routes.
*/
export class DuplexAggregateByteLedger {
private readonly ceiling: number;
private readonly telemetry: DuplexAggregateByteLedgerTelemetry;
private used = 0;
/** The owner registry. It holds every live token. */
private readonly liveTokens = new Set<ReservationTokenImpl>();
constructor(options: DuplexAggregateByteLedgerOptions) {
assertDuplexAggregateCeilingBytes(options.ceilingBytes);
this.ceiling = options.ceilingBytes;
this.telemetry = options.telemetry ?? NOOP_DUPLEX_AGGREGATE_BYTE_LEDGER_TELEMETRY;
}
/** The configured aggregate ceiling, in bytes. */
get ceilingBytes(): number {
return this.ceiling;
}
/** The current aggregate bytes in use across every live token. */
get bytesInUse(): number {
return this.used;
}
/** The current number of live tokens in the owner registry. */
get liveTokenCount(): number {
return this.liveTokens.size;
}
/**
* Reserve `bytes` for `owner`. Check and increment the gauge synchronously
* before the caller allocates. Return a held token when the reservation fits
* under the ceiling. Return `null` when `used + bytes` would pass the ceiling;
* the caller retains nothing and reports
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}.
*
* The byte amount must be a non-negative safe integer. A caller that passes any
* other value made a programming error, so the ledger throws and fails loud.
*/
reserve(owner: DuplexAggregateTokenOwner, bytes: number): ReservationToken | null {
if (!Number.isSafeInteger(bytes) || bytes < 0) {
throw new Error(
`A duplex byte reservation must be a non-negative safe integer; got ${String(bytes)}.`,
);
}
if (this.used + bytes > this.ceiling) {
this.telemetry.recordReservationRejection();
return null;
}
this.used += bytes;
const token = new ReservationTokenImpl(bytes, owner);
this.liveTokens.add(token);
this.telemetry.setBytesInUse(this.used);
return token;
}
/**
* Transfer a held token to a new owner label. Change only the owner. Never
* decrement and never re-reserve. This is the atomic pre-bind handoff: it moves
* the same reserved bytes from one representation to the next with no admission
* gap.
*
* A transfer of a token the ledger does not hold, or of an already-released
* token, is an accounting defect. The ledger records the fixed underflow
* counter and leaves the gauge unchanged.
*/
transfer(token: ReservationToken, owner: DuplexAggregateTokenOwner): void {
const impl = token as ReservationTokenImpl;
if (impl.state !== "held" || !this.liveTokens.has(impl)) {
this.telemetry.recordAccountingUnderflow();
return;
}
impl.owner = owner;
}
/**
* Release a held token. Remove it from the owner registry, mark it `released`,
* and decrement the gauge, all in one synchronous critical section before any
* `await` or external callback. A token releases one time.
*
* A second release of the same token, or a release of a token the ledger does
* not hold, is an accounting defect. The ledger records the fixed underflow
* counter and does not clamp the gauge a second time, so a real defect stays
* visible on the counter.
*/
release(token: ReservationToken): void {
const impl = token as ReservationTokenImpl;
if (impl.state === "released" || !this.liveTokens.delete(impl)) {
this.telemetry.recordAccountingUnderflow();
return;
}
impl.state = "released";
this.used -= impl.bytes;
this.telemetry.setBytesInUse(this.used);
}
}
@@ -0,0 +1,306 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DUPLEX_FRAME_VERSION, type DuplexBodyChunkFrame } from "./duplex-frame-codec.js";
import {
DuplexBodyReceiver,
type DuplexBodyReceiverConfig,
splitBodyIntoChunkFrames,
sweepStaleSpillDirectories,
} from "./duplex-body-spool.js";
// Read the whole reassembled body back, whatever path it took. A memory body
// returns its buffer; a spilled body streams from disk. The test uses this only
// to assert the round-trip content, never on a hot path.
async function readBodyBytes(body: {
spilled: boolean;
createReadStream: () => NodeJS.ReadableStream;
}): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of body.createReadStream()) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
let spillRoot: string;
beforeEach(async () => {
spillRoot = await fs.mkdtemp(path.join(os.tmpdir(), "duplex-spool-test-"));
});
afterEach(async () => {
await fs.rm(spillRoot, { recursive: true, force: true }).catch(() => undefined);
});
function makeReceiver(overrides: DuplexBodyReceiverConfig = {}): Promise<DuplexBodyReceiver> {
return DuplexBodyReceiver.create({ spillRootDir: spillRoot, ...overrides });
}
const RAW = 8;
// Feed a whole body through a receiver as an envelope plus chunk frames. Return
// the settled body handle. The caller pushes the chunks the split produces.
async function transfer(
receiver: DuplexBodyReceiver,
id: string,
body: Buffer,
): ReturnType<DuplexBodyReceiver["begin"]> {
const promise = receiver.begin(id, body.length);
const frames = splitBodyIntoChunkFrames(id, body, DUPLEX_FRAME_VERSION, RAW);
for (const frame of frames) {
const result = receiver.pushChunk(frame);
expect(result.ok).toBe(true);
}
return promise;
}
describe("duplex body reassembler — round trip", () => {
it("reassembles a small body on the memory path", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
const source = Buffer.from("hello duplex body");
const body = await transfer(receiver, "req-mem", source);
expect(body.spilled).toBe(false);
expect(body.byteCount).toBe(source.length);
expect(body.readMemory().equals(source)).toBe(true);
expect((await readBodyBytes(body)).equals(source)).toBe(true);
await body.dispose();
});
it("reassembles a large body on the spill path without holding it in memory", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW });
const source = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789ABCD"); // 40 bytes
const body = await transfer(receiver, "req-spill", source);
expect(body.spilled).toBe(true);
expect(body.byteCount).toBe(source.length);
// A spilled body never loads the whole body into memory on the receive side.
expect(() => body.readMemory()).toThrow();
expect(receiver.spillUsedBytes).toBe(source.length);
expect(receiver.spillOpenFiles).toBe(1);
expect((await readBodyBytes(body)).equals(source)).toBe(true);
await body.dispose();
// The dispose path unlinks the file and releases the reservation.
expect(receiver.spillUsedBytes).toBe(0);
expect(receiver.spillOpenFiles).toBe(0);
const entries = await fs.readdir(receiver.spillDir);
expect(entries).toHaveLength(0);
await receiver.destroy();
});
it("completes a zero-length body at once and accepts no chunk", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW });
const body = await receiver.begin("req-zero", 0);
expect(body.byteCount).toBe(0);
expect(body.readMemory().length).toBe(0);
const stray = receiver.pushChunk({
version: DUPLEX_FRAME_VERSION,
type: "body_chunk",
id: "req-zero",
seq: 0,
data: Buffer.from("x").toString("base64"),
});
expect(stray.ok).toBe(false);
if (!stray.ok) expect(stray.error.code).toBe("zero_body_has_chunk");
await body.dispose();
});
});
describe("duplex body reassembler — rejection matrix", () => {
function chunk(id: string, seq: number, data: string): DuplexBodyChunkFrame {
return { version: DUPLEX_FRAME_VERSION, type: "body_chunk", id, seq, data };
}
it("rejects a duplicate seq", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("d", 16);
expect(receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true);
const dup = receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 2).toString("base64")));
expect(dup.ok).toBe(false);
if (!dup.ok) expect(dup.error.code).toBe("seq_out_of_order");
});
it("rejects a gap in the seq order", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("g", 24);
expect(receiver.pushChunk(chunk("g", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true);
const gap = receiver.pushChunk(chunk("g", 2, Buffer.alloc(RAW, 2).toString("base64")));
expect(gap.ok).toBe(false);
if (!gap.ok) expect(gap.error.code).toBe("seq_out_of_order");
});
it("rejects a reordered seq", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("r", 24);
const reorder = receiver.pushChunk(chunk("r", 1, Buffer.alloc(RAW, 1).toString("base64")));
expect(reorder.ok).toBe(false);
if (!reorder.ok) expect(reorder.error.code).toBe("seq_out_of_order");
});
it("rejects non-canonical base64", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
const begun = receiver.begin("b", 8);
begun.catch(() => undefined);
// "AB==" carries non-zero bits in the final sextet, so it decodes to one
// byte and re-encodes to "AA==". The round-trip mismatch marks it not
// canonical.
const bad = receiver.pushChunk(chunk("b", 0, "AB=="));
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.error.code).toBe("chunk_not_canonical_base64");
});
it("rejects a short non-final chunk (truncation)", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("t", 24);
// A non-final chunk must be exactly RAW bytes. A 4-byte chunk that does not
// complete the 24-byte body is a truncation.
const truncation = receiver.pushChunk(chunk("t", 0, Buffer.alloc(4, 1).toString("base64")));
expect(truncation.ok).toBe(false);
if (!truncation.ok) expect(truncation.error.code).toBe("chunk_wrong_size");
});
it("rejects an empty non-final chunk", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("e", 16);
const empty = receiver.pushChunk(chunk("e", 0, ""));
expect(empty.ok).toBe(false);
if (!empty.ok) expect(empty.error.code).toBe("empty_chunk");
});
it("rejects an overrun past the declared body size", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
// A 12-byte body takes one full RAW chunk, then a final chunk of 4 bytes. A
// second full RAW chunk overruns the declared size.
void receiver.begin("o", 12);
expect(receiver.pushChunk(chunk("o", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true);
const overrun = receiver.pushChunk(chunk("o", 1, Buffer.alloc(RAW, 2).toString("base64")));
expect(overrun.ok).toBe(false);
if (!overrun.ok) expect(overrun.error.code).toBe("body_overrun");
});
it("rejects an over-size final chunk", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
void receiver.begin("os", 40);
const over = receiver.pushChunk(chunk("os", 0, Buffer.alloc(RAW + 4, 1).toString("base64")));
expect(over.ok).toBe(false);
if (!over.ok) expect(over.error.code).toBe("chunk_wrong_size");
});
it("rejects a chunk that arrives with no matching envelope", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
const orphan = receiver.pushChunk(chunk("missing", 0, Buffer.alloc(RAW, 1).toString("base64")));
expect(orphan.ok).toBe(false);
if (!orphan.ok) expect(orphan.error.code).toBe("chunk_without_envelope");
});
it("rejects a chunk that arrives after completion", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW });
const promise = receiver.begin("c", RAW);
expect(receiver.pushChunk(chunk("c", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true);
await promise;
const late = receiver.pushChunk(chunk("c", 1, Buffer.alloc(RAW, 2).toString("base64")));
expect(late.ok).toBe(false);
if (!late.ok) expect(late.error.code).toBe("chunk_after_completion");
});
});
describe("duplex body reassembler — spill caps fail closed", () => {
it("fails closed when a write exceeds the per-channel byte cap and unlinks the partial file", async () => {
// A 5-byte cap admits the first RAW=8 chunk write? No: reserve 8 > cap 5, so
// the first write fails closed. Use a cap that admits one chunk but not two.
const receiver = await makeReceiver({
spillThresholdBytes: 4,
rawChunkBytes: RAW,
channelSpillByteCap: RAW + 2,
});
const promise = receiver.begin("cap", 24);
expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true);
// The second chunk write reserves past the byte cap.
receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 1, data: Buffer.alloc(RAW, 2).toString("base64") });
await expect(promise).rejects.toMatchObject({ code: "spill_cap_exceeded" });
// The partial file is gone and the reservation released.
expect(receiver.spillUsedBytes).toBe(0);
expect(receiver.spillOpenFiles).toBe(0);
const entries = await fs.readdir(receiver.spillDir);
expect(entries).toHaveLength(0);
await receiver.destroy();
});
it("fails closed when the open-file cap is reached", async () => {
const receiver = await makeReceiver({
spillThresholdBytes: 4,
rawChunkBytes: RAW,
channelSpillFileCap: 1,
});
// The first spill body claims the one file slot and stays open. The channel
// dispose rejects this in-flight body, so attach the catch at once.
const first = receiver.begin("f1", 24);
first.catch(() => undefined);
expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "f1", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true);
// The second spill body cannot claim a file slot.
await expect(receiver.begin("f2", 24)).rejects.toMatchObject({ code: "spill_cap_exceeded" });
expect(receiver.spillOpenFiles).toBe(1);
// The dispose of the in-flight first body rejects its begin promise with a
// terminal channel-closed error and frees the file slot.
await receiver.disposeBody("f1");
await expect(first).rejects.toMatchObject({ code: "channel_closed" });
expect(receiver.spillOpenFiles).toBe(0);
await receiver.destroy();
});
});
describe("duplex body reassembler — lifecycle cleanup", () => {
it("releases the reservation and unlinks the file on channel death", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW });
// The channel death rejects this in-flight body, so attach the catch at once.
const dying = receiver.begin("death", 24);
dying.catch(() => undefined);
expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "death", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true);
// Wait for the write to land, so the file exists before the channel dies.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(receiver.spillOpenFiles).toBe(1);
await receiver.disposeAll();
await expect(dying).rejects.toMatchObject({ code: "channel_closed" });
expect(receiver.spillUsedBytes).toBe(0);
expect(receiver.spillOpenFiles).toBe(0);
const entries = await fs.readdir(receiver.spillDir);
expect(entries).toHaveLength(0);
await receiver.destroy();
});
});
describe("duplex body reassembler — spill file safety", () => {
it("creates the spill directory with mode 0700 and each spill file with mode 0600", async () => {
const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW });
const dirStat = await fs.stat(receiver.spillDir);
expect(dirStat.mode & 0o777).toBe(0o700);
// The final destroy rejects this in-flight body, so attach the catch at once.
const pending = receiver.begin("mode", 8);
pending.catch(() => undefined);
receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "mode", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") });
await new Promise((resolve) => setTimeout(resolve, 20));
const entries = await fs.readdir(receiver.spillDir);
expect(entries).toHaveLength(1);
// The file name is random, so no frame id rides the path.
expect(entries[0]).not.toContain("mode");
const fileStat = await fs.stat(path.join(receiver.spillDir, entries[0]));
expect(fileStat.mode & 0o777).toBe(0o600);
await receiver.destroy();
});
});
describe("duplex body reassembler — startup sweep", () => {
it("removes a stale spill directory from a dead process but keeps a live one", async () => {
const deadPid = 999999; // A pid that is almost never alive.
const staleDir = path.join(spillRoot, `paperclip-duplex-spill-${deadPid}-stale`);
await fs.mkdir(staleDir);
await fs.writeFile(path.join(staleDir, "leftover.body"), "x");
const liveDir = path.join(spillRoot, `paperclip-duplex-spill-${process.pid}-live`);
await fs.mkdir(liveDir);
await sweepStaleSpillDirectories(spillRoot);
await expect(fs.stat(staleDir)).rejects.toThrow();
// The sweep keeps a directory that belongs to a live process.
await expect(fs.stat(liveDir)).resolves.toBeDefined();
});
});
@@ -0,0 +1,596 @@
/**
* Receive-side body reassembly and spill for the sandbox duplex channel.
*
* The duplex codec splits one request body or one response body into an envelope
* frame plus a sequence of `body_chunk` frames. This module reassembles those
* chunks back into one body. The receiver reads `bodyByteCount` from the envelope
* first, then chooses one of two paths before it reads the first chunk:
* - memory path: a body at or below the spill threshold stays in a memory
* buffer.
* - spill path: a body above the spill threshold streams to a temporary file,
* so the receiver never holds a whole large body in memory.
*
* The spill path bounds the disk a single channel can consume. A per-channel
* arena caps the bytes on disk and the number of open spill files. The receiver
* reserves a file slot and reserves bytes before it opens a file and before each
* write. A reserve that exceeds a cap fails closed: it rejects the body as a
* resource error and unlinks any partial file.
*
* The reassembler treats every frame as untrusted. It rejects a bad chunk as a
* loud, terminal protocol error: a non-canonical base64 payload, a wrong chunk
* size, an out-of-order `seq`, a total that does not equal `bodyByteCount`, or a
* chunk that arrives for a completed or unknown body.
*/
import { randomBytes } from "node:crypto";
import { createReadStream } from "node:fs";
import { promises as fs } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import {
DUPLEX_BODY_CHUNK_RAW_BYTES,
type DuplexBodyChunkFrame,
} from "./duplex-frame-codec.js";
/**
* The default spill threshold, in bytes. A body at or below this size stays in
* memory. A body above it spills to disk. The value is 1 MiB. It stays
* injectable so a test can lower it and exercise the spill path.
*/
export const DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES = 1024 * 1024;
/**
* The default per-channel spill byte cap. The receiver keeps at most this many
* bytes on disk at one time for one channel. The value is 64 MiB. It is about
* 0.64% of the default 10 GiB sandbox disk and stays far below the security
* ceiling of 25% of the disk quota. It stays injectable so a test can lower it.
*/
export const DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP = 64 * 1024 * 1024;
/**
* The default per-channel open-file cap. The receiver keeps at most this many
* spill files open at one time for one channel. The value derives from the byte
* cap and the spill threshold: 64 MiB divided by the 1 MiB spill floor gives 64.
* It stays injectable so a test can lower it.
*/
export const DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP = 64;
/** The prefix of every spill directory name. The startup sweep matches it. */
const SPILL_DIR_PREFIX = "paperclip-duplex-spill-";
/** The config a caller passes to build a {@link DuplexBodyReceiver}. */
export interface DuplexBodyReceiverConfig {
/** The spill threshold in bytes. Defaults to {@link DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES}. */
spillThresholdBytes?: number;
/** The fixed raw chunk size in bytes. Defaults to {@link DUPLEX_BODY_CHUNK_RAW_BYTES}. */
rawChunkBytes?: number;
/** The per-channel spill byte cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP}. */
channelSpillByteCap?: number;
/** The per-channel open-file cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP}. */
channelSpillFileCap?: number;
/** The root directory that holds the spill directory. Defaults to the OS temp dir. */
spillRootDir?: string;
/** A diagnostic sink for a non-fatal cleanup note. The receiver names no body id here. */
logger?: (message: string) => void;
}
/** The reason the reassembler rejected a body. Each reason is a terminal protocol error. */
export type DuplexBodyErrorCode =
| "chunk_without_envelope"
| "chunk_after_completion"
| "seq_out_of_order"
| "chunk_not_canonical_base64"
| "chunk_wrong_size"
| "empty_chunk"
| "body_overrun"
| "zero_body_has_chunk"
| "spill_cap_exceeded"
| "spill_write_failed"
| "path_escape"
| "channel_closed";
/** A reassembly error. The receiver returns it; it never leaks a raw path or a raw provider string. */
export class DuplexBodyError extends Error {
readonly code: DuplexBodyErrorCode;
constructor(code: DuplexBodyErrorCode, message: string) {
super(message);
this.name = "DuplexBodyError";
this.code = code;
}
}
/**
* One reassembled body. The memory path holds the whole body in a buffer. The
* spill path holds a temporary file. A caller reads the memory buffer, or opens
* a fresh read stream, then disposes the body once.
*/
export interface ReassembledBody {
/** The raw byte length of the body. */
readonly byteCount: number;
/** True when the body spilled to disk. */
readonly spilled: boolean;
/**
* The whole body as a buffer. The memory path returns it. The spill path
* throws, because the spill path must never load a whole large body into
* memory on the receive side. A caller streams a spilled body instead.
*/
readMemory(): Buffer;
/** A fresh read stream over the body. The memory path streams one buffer; the spill path streams the file. */
createReadStream(): Readable;
/** Release the body. The spill path unlinks the file and releases the reservation exactly once. */
dispose(): Promise<void>;
}
/**
* The per-channel arena that bounds the spill disk use. It tracks the bytes on
* disk and the open files, and it owns the spill directory. It hands out
* reservations and takes them back. The reservation math is synchronous, so a
* concurrent reserve never races past a cap.
*/
class SpillArena {
usedBytes = 0;
openFiles = 0;
constructor(
readonly dir: string,
private readonly byteCap: number,
private readonly fileCap: number,
) {}
/** Reserve one open-file slot. Return false when the file cap is already at the limit. */
reserveFile(): boolean {
if (this.openFiles >= this.fileCap) return false;
this.openFiles += 1;
return true;
}
/** Release one open-file slot. */
releaseFile(): void {
if (this.openFiles > 0) this.openFiles -= 1;
}
/** Reserve `n` bytes of disk. Return false when the byte cap would be exceeded. */
reserveBytes(n: number): boolean {
if (this.usedBytes + n > this.byteCap) return false;
this.usedBytes += n;
return true;
}
/** Release `n` bytes of disk. */
releaseBytes(n: number): void {
this.usedBytes -= n;
if (this.usedBytes < 0) this.usedBytes = 0;
}
}
/** The internal state of one in-flight body. */
interface ActiveBody {
bodyByteCount: number;
nextSeq: number;
receivedBytes: number;
spilled: boolean;
/** Memory-path accumulation. Empty for the spill path. */
memoryChunks: Buffer[];
/** The spill file path. Null for the memory path. */
filePath: string | null;
/** The open spill file handle. Null for the memory path or before the first write. */
handle: FileHandle | null;
/** The bytes this body reserved in the arena. The dispose path releases exactly this many. */
reservedBytes: number;
/** True when the body holds an arena file slot that the dispose path must release. */
holdsFileSlot: boolean;
/** True once the dispose path ran, so it runs its release once. */
disposed: boolean;
/** Resolve the completion promise the caller awaits. */
resolve: (body: ReassembledBody) => void;
/** Reject the completion promise on a terminal error. */
reject: (error: DuplexBodyError) => void;
/** Serialize the spill writes, so the chunks land on disk in `seq` order. */
writeChain: Promise<void>;
/** True once the body settled (resolved or rejected), so a late chunk never double-settles. */
settled: boolean;
}
/** The synchronous result of one `pushChunk` call. */
export type DuplexBodyPushResult =
| { ok: true }
| { ok: false; error: DuplexBodyError };
/**
* A per-channel receive-side body reassembler. One instance serves one channel.
* The caller feeds it one envelope per body, then the `body_chunk` frames for
* that body. The reassembler reassembles the body on the memory path or the
* spill path and settles one completion promise per body.
*/
export class DuplexBodyReceiver {
private readonly spillThresholdBytes: number;
private readonly rawChunkBytes: number;
private readonly arena: SpillArena;
private readonly logger?: (message: string) => void;
private readonly active = new Map<string, ActiveBody>();
private constructor(arena: SpillArena, config: DuplexBodyReceiverConfig) {
this.arena = arena;
this.spillThresholdBytes =
config.spillThresholdBytes ?? DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES;
this.rawChunkBytes = config.rawChunkBytes ?? DUPLEX_BODY_CHUNK_RAW_BYTES;
this.logger = config.logger;
}
/**
* Build a receiver. The receiver owns the spill directory. It creates the
* directory with `mkdtemp` and mode 0700, then sweeps stale spill directories
* from a prior process. The sweep runs and awaits before the receiver serves a
* body, so no caller races a half-swept directory.
*/
static async create(config: DuplexBodyReceiverConfig = {}): Promise<DuplexBodyReceiver> {
const root = config.spillRootDir ?? os.tmpdir();
await sweepStaleSpillDirectories(root, config.logger);
const dir = await fs.mkdtemp(path.join(root, `${SPILL_DIR_PREFIX}${process.pid}-`));
// `mkdtemp` creates the directory with mode 0700 on a POSIX host by default,
// but a permissive umask can widen it. Set the mode so the owner alone reads,
// writes, and lists the spill directory.
await fs.chmod(dir, 0o700);
const arena = new SpillArena(
dir,
config.channelSpillByteCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP,
config.channelSpillFileCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP,
);
return new DuplexBodyReceiver(arena, config);
}
/** The spill directory this receiver owns. A test reads it to assert containment and cleanup. */
get spillDir(): string {
return this.arena.dir;
}
/** The bytes on disk across all in-flight bodies for this channel. A test asserts the reservation. */
get spillUsedBytes(): number {
return this.arena.usedBytes;
}
/** The open spill files for this channel. A test asserts the file-slot reservation. */
get spillOpenFiles(): number {
return this.arena.openFiles;
}
/**
* Begin one body. The caller passes the envelope `id` and `bodyByteCount`. The
* receiver chooses the memory path or the spill path from `bodyByteCount`
* before the first chunk arrives. A `bodyByteCount` of 0 completes at once with
* an empty body and accepts no chunk. The returned promise resolves with the
* reassembled body, or rejects with a {@link DuplexBodyError} on a terminal
* protocol or resource error.
*/
begin(id: string, bodyByteCount: number): Promise<ReassembledBody> {
return new Promise<ReassembledBody>((resolve, reject) => {
// A repeated envelope id for an in-flight body is a protocol violation. The
// caller must not begin the same id twice. Reject the new begin and leave
// the in-flight body untouched.
if (this.active.has(id)) {
reject(new DuplexBodyError("chunk_after_completion", "duplicate body envelope id"));
return;
}
const body: ActiveBody = {
bodyByteCount,
nextSeq: 0,
receivedBytes: 0,
spilled: bodyByteCount > this.spillThresholdBytes,
memoryChunks: [],
filePath: null,
handle: null,
reservedBytes: 0,
holdsFileSlot: false,
disposed: false,
resolve,
reject,
writeChain: Promise.resolve(),
settled: false,
};
this.active.set(id, body);
if (bodyByteCount === 0) {
// The zero-length body completes at once. It accepts no chunk, so the
// receiver keeps the id in a completed state to reject a stray chunk.
this.complete(id, body);
return;
}
if (body.spilled) {
// Reserve one file slot before the receiver opens the spill file. A
// reserve past the file cap fails closed at once.
if (!this.arena.reserveFile()) {
this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill file cap reached"));
return;
}
body.holdsFileSlot = true;
// Open the spill file with a random name and mode 0600. The name never
// derives from the frame id, so no id rides a path. Verify the resolved
// path stays inside the spill directory before the receiver writes.
this.openSpillFile(id, body);
}
});
}
/**
* Push one `body_chunk` frame. The receiver validates the chunk against the
* in-flight body, then appends it on the memory path or the spill path. The
* return value reports a synchronous validation outcome. A spill write failure
* or a completion rejects the begin promise instead.
*/
pushChunk(frame: DuplexBodyChunkFrame): DuplexBodyPushResult {
const body = this.active.get(frame.id);
if (!body) {
return fail("chunk_without_envelope", "body_chunk arrived with no matching envelope");
}
if (body.bodyByteCount === 0) {
return fail("zero_body_has_chunk", "a zero-length body accepts no body_chunk");
}
if (body.settled) {
return fail("chunk_after_completion", "body_chunk arrived after the body completed");
}
if (frame.seq !== body.nextSeq) {
return fail("seq_out_of_order", "body_chunk seq is a duplicate, a gap, or a reorder");
}
// Decode the base64 payload and confirm it is canonical. A non-canonical
// payload re-encodes to a different string, so the round-trip catches it.
const decoded = Buffer.from(frame.data, "base64");
if (decoded.toString("base64") !== frame.data) {
return fail("chunk_not_canonical_base64", "body_chunk data is not canonical base64");
}
if (decoded.length === 0) {
return fail("empty_chunk", "body_chunk is empty");
}
const nextReceived = body.receivedBytes + decoded.length;
if (nextReceived > body.bodyByteCount) {
return fail("body_overrun", "body_chunk overruns the declared body size");
}
const isFinal = nextReceived === body.bodyByteCount;
if (!isFinal && decoded.length !== this.rawChunkBytes) {
// A non-final chunk must be exactly the fixed raw slice size. A short
// non-final chunk is a truncation; an over-size chunk is a framing fault.
return fail("chunk_wrong_size", "a non-final body_chunk is not the fixed slice size");
}
if (decoded.length > this.rawChunkBytes) {
// The final chunk must also stay within the fixed raw slice size.
return fail("chunk_wrong_size", "a body_chunk exceeds the fixed slice size");
}
body.nextSeq += 1;
body.receivedBytes = nextReceived;
if (body.spilled) {
this.appendSpill(frame.id, body, decoded, isFinal);
} else {
body.memoryChunks.push(decoded);
if (isFinal) this.complete(frame.id, body);
}
return { ok: true };
}
/**
* Dispose one body by id, whatever its state. The dispose path unlinks the
* spill file and releases the arena reservation exactly once. The caller runs
* it on every settle path and on channel death, so a spill file never lingers.
*/
async disposeBody(id: string): Promise<void> {
const body = this.active.get(id);
if (!body) return;
this.active.delete(id);
await this.release(body);
}
/**
* Dispose every in-flight body. The caller runs it on channel death, so a
* channel that dies mid-body leaves no spill file behind.
*/
async disposeAll(): Promise<void> {
const ids = [...this.active.keys()];
await Promise.all(ids.map((id) => this.disposeBody(id)));
}
/**
* Remove the spill directory this receiver owns. The caller runs it after it
* disposes every body, on channel shutdown.
*/
async destroy(): Promise<void> {
await this.disposeAll();
await fs.rm(this.arena.dir, { recursive: true, force: true }).catch(() => undefined);
}
private openSpillFile(id: string, body: ActiveBody): void {
const name = `${randomBytes(24).toString("hex")}.body`;
const filePath = path.join(this.arena.dir, name);
// Path containment. The resolved file path must stay inside the spill
// directory. A generated random name never escapes, but the check is defense
// in depth against a future name scheme.
const resolvedDir = path.resolve(this.arena.dir);
const resolvedFile = path.resolve(filePath);
if (resolvedFile !== path.join(resolvedDir, name) || !resolvedFile.startsWith(resolvedDir + path.sep)) {
this.settleReject(id, body, new DuplexBodyError("path_escape", "spill path escapes the spill directory"));
return;
}
body.filePath = filePath;
// Open the file once, exclusive, mode 0600. The write chain appends each
// chunk in order to this one handle.
body.writeChain = body.writeChain.then(async () => {
body.handle = await fs.open(filePath, "wx", 0o600);
});
body.writeChain.catch((error) => {
this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error)));
});
}
private appendSpill(id: string, body: ActiveBody, decoded: Buffer, isFinal: boolean): void {
// Reserve the bytes before the write. A reserve past the byte cap fails
// closed: reject the body and unlink the partial file.
if (!this.arena.reserveBytes(decoded.length)) {
this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill byte cap reached"));
return;
}
body.reservedBytes += decoded.length;
body.writeChain = body.writeChain.then(async () => {
if (body.settled) return;
if (!body.handle) throw new Error("spill file handle is not open");
await body.handle.write(decoded);
if (isFinal) {
await body.handle.close();
body.handle = null;
this.complete(id, body);
}
});
body.writeChain.catch((error) => {
if (error instanceof DuplexBodyError) return;
this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error)));
});
}
private complete(id: string, body: ActiveBody): void {
if (body.settled) return;
body.settled = true;
const receiver = this;
const handle: ReassembledBody = {
byteCount: body.bodyByteCount,
spilled: body.spilled,
readMemory(): Buffer {
if (body.spilled) {
throw new DuplexBodyError("spill_write_failed", "a spilled body has no in-memory buffer");
}
return Buffer.concat(body.memoryChunks);
},
createReadStream(): Readable {
if (body.spilled && body.filePath) return createReadStream(body.filePath);
return Readable.from(Buffer.concat(body.memoryChunks));
},
async dispose(): Promise<void> {
await receiver.disposeBody(id);
},
};
body.resolve(handle);
}
private settleReject(id: string, body: ActiveBody, error: DuplexBodyError): void {
if (body.settled) return;
body.settled = true;
// Release the reservation and unlink the partial file before the reject, so
// a rejected body never keeps a spill file or a reservation. The reject fires
// after the release, so a caller that awaits the reject sees a clean arena.
void this.disposeBody(id).finally(() => body.reject(error));
}
private async release(body: ActiveBody): Promise<void> {
if (body.disposed) return;
body.disposed = true;
// Wait for the pending writes to settle, so the unlink never races an open
// handle. Swallow a write error here; the reject path already reported it.
await body.writeChain.catch(() => undefined);
if (body.handle) {
await body.handle.close().catch(() => undefined);
body.handle = null;
}
if (body.filePath) {
await fs.rm(body.filePath, { force: true }).catch((error) => {
this.logger?.(`Duplex spill unlink failed: ${errorText(error)}`);
});
}
if (body.reservedBytes > 0) {
this.arena.releaseBytes(body.reservedBytes);
body.reservedBytes = 0;
}
if (body.holdsFileSlot) {
this.arena.releaseFile();
body.holdsFileSlot = false;
}
// A body that the channel disposes before it settles must not leave its
// begin promise pending. Reject it with a terminal channel-closed error, so
// a caller that awaits the body sees the channel death. A body that already
// settled keeps its outcome: `complete` resolves it, and `settleReject` sets
// `settled` before it calls dispose, so this guard never double-settles.
if (!body.settled) {
body.settled = true;
body.reject(
new DuplexBodyError("channel_closed", "the channel closed before the body completed"),
);
}
}
}
function fail(code: DuplexBodyErrorCode, message: string): DuplexBodyPushResult {
return { ok: false, error: new DuplexBodyError(code, message) };
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Split a whole body buffer into `body_chunk` frames. The send side reads the
* source body once, then calls this to build the frames. Each frame carries one
* raw slice of `rawChunkBytes` as base64 text, except the final frame, which
* carries the remaining bytes. A zero-length body yields no frame.
*/
export function splitBodyIntoChunkFrames(
id: string,
body: Buffer,
version: number,
rawChunkBytes: number = DUPLEX_BODY_CHUNK_RAW_BYTES,
): DuplexBodyChunkFrame[] {
const frames: DuplexBodyChunkFrame[] = [];
let seq = 0;
for (let offset = 0; offset < body.length; offset += rawChunkBytes) {
const slice = body.subarray(offset, offset + rawChunkBytes);
frames.push({
version,
type: "body_chunk",
id,
seq,
data: slice.toString("base64"),
});
seq += 1;
}
return frames;
}
/**
* Sweep stale spill directories from a prior process. The receiver runs it at
* startup, before it serves a body. It removes a spill directory whose embedded
* pid is not this process and is not a live process, so a crash that left a
* directory behind does not leak disk. It never removes a directory of a live
* process, so a concurrent channel keeps its own spill directory.
*/
export async function sweepStaleSpillDirectories(
root: string,
logger?: (message: string) => void,
): Promise<void> {
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory() || !entry.name.startsWith(SPILL_DIR_PREFIX)) return;
const pid = parsePidFromSpillDir(entry.name);
if (pid !== null && pid !== process.pid && isProcessAlive(pid)) return;
if (pid === process.pid) return;
await fs.rm(path.join(root, entry.name), { recursive: true, force: true }).catch((error) => {
logger?.(`Duplex stale spill sweep failed: ${errorText(error)}`);
});
}),
);
}
/** Read the pid segment from a spill directory name, or null when it is absent. */
function parsePidFromSpillDir(name: string): number | null {
const rest = name.slice(SPILL_DIR_PREFIX.length);
const dash = rest.indexOf("-");
const pidText = dash === -1 ? rest : rest.slice(0, dash);
const pid = Number(pidText);
return Number.isInteger(pid) && pid > 0 ? pid : null;
}
/** Return true when a process with the pid is alive. A signal 0 probes without a real signal. */
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM means the process exists but this process cannot signal it. ESRCH
// means no such process. Treat only ESRCH as dead.
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
@@ -0,0 +1,384 @@
import { afterEach, describe, expect, it } from "vitest";
import {
createDuplexBridgeBroker,
type DuplexBridgeBroker,
type DuplexBrokerForwardResult,
} from "./duplex-bridge-broker.js";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
} from "./duplex-aggregate-byte-ledger.js";
import {
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
encodeDuplexFrame,
type DuplexRequestFrame,
type DuplexResponseFrame,
} from "./duplex-frame-codec.js";
import { splitBodyIntoChunkFrames } from "./duplex-body-spool.js";
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
/**
* Regression harness for the broker aggregate byte ledger charging.
*
* The harness feeds request frames straight into the broker through an in-memory
* channel, the same as a provider that controls the transport. A test controls
* when each forward settles, so it can assert the ledger charge while the forward
* is still in flight and after it settles.
*/
/** One pending forward a test settles by hand. */
interface PendingForward {
id: string;
resolve: (result: DuplexBrokerForwardResult) => void;
reject: (error: Error) => void;
settled: boolean;
}
/**
* One reassembled response the broker wrote back. The broker writes a response as
* one envelope frame that carries `bodyByteCount`, then the `body_chunk` frames
* that carry the body. The harness reassembles the body and exposes it as `body`.
*/
type ReassembledResponse = DuplexResponseFrame & { body: string };
/** One request the harness feeds: the envelope frame plus its raw body text. */
interface RequestInput {
frame: DuplexRequestFrame;
bodyText: string;
}
/** The in-memory channel plus the levers a test uses to drive the broker. */
interface FakeChannelHarness {
channel: CommandManagedDuplexChannel;
feed: (input: RequestInput) => void;
exit: () => void;
responses: ReassembledResponse[];
forwards: PendingForward[];
resolveForward: (id: string, body?: string) => void;
}
/** Build one valid request: the envelope carries `bodyByteCount`, the body rides body_chunk frames. */
function requestFrame(id: string, method = "POST"): RequestInput {
const bodyText = JSON.stringify({ id });
return {
frame: {
version: DUPLEX_FRAME_VERSION,
type: "request",
id,
method,
path: `/api/issues/${id}`,
query: "",
headers: { "content-type": "application/json" },
bodyByteCount: Buffer.byteLength(bodyText, "utf8"),
},
bodyText,
};
}
function createFakeChannelHarness(): FakeChannelHarness {
const responses: ReassembledResponse[] = [];
const forwards: PendingForward[] = [];
const writtenDecoder = new DuplexFrameDecoder();
const responseAssembly = new Map<
string,
{ frame: DuplexResponseFrame; received: number; chunks: Buffer[] }
>();
let dataListener: ((chunk: string) => void) | null = null;
let exitListener: ((exit: { exitCode: number | null }) => void) | null = null;
const channel: CommandManagedDuplexChannel = {
write: (data) => {
for (const result of writtenDecoder.push(data)) {
if (!result.ok) continue;
const frame = result.frame;
if (frame.type === "response") {
if (frame.bodyByteCount === 0) {
responses.push({ ...frame, body: "" });
} else {
responseAssembly.set(frame.id, { frame, received: 0, chunks: [] });
}
} else if (frame.type === "body_chunk") {
const assembly = responseAssembly.get(frame.id);
if (!assembly) continue;
const decoded = Buffer.from(frame.data, "base64");
assembly.received += decoded.length;
assembly.chunks.push(decoded);
if (assembly.received >= assembly.frame.bodyByteCount) {
responseAssembly.delete(frame.id);
responses.push({
...assembly.frame,
body: Buffer.concat(assembly.chunks).toString("utf8"),
});
}
}
}
},
onData: (listener) => {
dataListener = listener;
},
onExit: (listener) => {
exitListener = listener;
},
stop: () => undefined,
close: () => Promise.resolve(),
};
return {
channel,
feed: ({ frame, bodyText }) => {
if (!dataListener) throw new Error("The broker did not bind the data listener.");
dataListener(encodeDuplexFrame(frame));
if (bodyText.length > 0) {
for (const chunk of splitBodyIntoChunkFrames(
frame.id,
Buffer.from(bodyText, "utf8"),
DUPLEX_FRAME_VERSION,
)) {
dataListener(encodeDuplexFrame(chunk));
}
}
},
exit: () => {
if (!exitListener) throw new Error("The broker did not bind the exit listener.");
exitListener({ exitCode: 0 });
},
responses,
forwards,
resolveForward: (id, body = JSON.stringify({ ok: true })) => {
const forward = [...forwards].reverse().find((entry) => entry.id === id && !entry.settled);
if (!forward) throw new Error(`No unsettled forward for id ${id}.`);
forward.settled = true;
forward.resolve({ status: 200, headers: { "content-type": "application/json" }, body });
},
};
}
/** A forward handler that hands each call to the harness and never auto-resolves. */
function controllableForward(harness: FakeChannelHarness) {
return (request: DuplexRequestFrame): Promise<DuplexBrokerForwardResult> =>
new Promise<DuplexBrokerForwardResult>((resolve, reject) => {
harness.forwards.push({ id: request.id, resolve, reject, settled: false });
});
}
/**
* Wait for the microtasks and one macrotask to settle, so the broker reassembles
* a request body, dispatches its forward, and runs each settled promise handler.
*/
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("duplex bridge broker aggregate byte ledger", () => {
const brokers: DuplexBridgeBroker[] = [];
afterEach(async () => {
while (brokers.length > 0) {
const broker = brokers.pop();
if (broker) await broker.close();
}
});
it("charges request-frame, request-payload, and seen-id tokens, then releases the request tokens on forward settlement", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
// The dispatch retains three tokens at the request envelope: the raw frame, the
// normalized payload, and the no-replay set entry. The reservation is
// synchronous, so the charge holds before the body reassembles.
expect(ledger.liveTokenCount).toBe(3);
expect(ledger.bytesInUse).toBeGreaterThan(0);
const chargedInFlight = ledger.bytesInUse;
// The broker reassembles the request body, then dispatches the forward.
await flush();
harness.resolveForward("req-1");
await flush();
// The forward settled. The finally owner released the request-frame and the
// request-payload tokens. The seen-id token stays charged for the channel
// lifetime, so exactly one token remains.
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBeGreaterThan(0);
expect(ledger.bytesInUse).toBeLessThan(chargedInFlight);
// A close releases the seen-id token, so the ledger returns to zero.
await broker.close();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("keeps request tokens charged when the response timer answers before the forward settles, and releases them on settlement", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
// Squeeze the nested budgets so the response timer fires quickly while the
// forward stays unsettled.
budgets: { forwardTimeoutMs: 5, responseBudgetMs: 10, gatewayWaitMs: 20 },
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1", "GET"));
expect(ledger.liveTokenCount).toBe(3);
// Wait past the response budget so the backstop answers the gateway while the
// forward is still in flight.
await new Promise((resolve) => setTimeout(resolve, 40));
expect(harness.responses.some((frame) => frame.id === "req-1")).toBe(true);
// The gateway got its answer, but the forward has not settled. The request
// tokens and the seen-id token stay charged: nothing released yet.
expect(ledger.liveTokenCount).toBe(3);
// Settle the orphaned forward. Its finally owner now releases the two request
// tokens; the seen-id token still stays for the channel lifetime.
harness.resolveForward("req-1");
await flush();
expect(ledger.liveTokenCount).toBe(1);
await broker.close();
expect(ledger.bytesInUse).toBe(0);
});
it("refuses a one-byte-over dispatch with the fixed marker and retains nothing", async () => {
const harness = createFakeChannelHarness();
// Measure the exact per-request charge with a throwaway broker, so the test
// can size a ceiling one byte below it and force the reservation to fail.
const probeHarness = createFakeChannelHarness();
const measured = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const measuringBroker = await createDuplexBridgeBroker({
channel: probeHarness.channel,
forwardRequest: controllableForward(probeHarness),
duplexAggregateByteLedger: measured,
});
measuringBroker.start();
probeHarness.feed(requestFrame("req-1"));
const perRequestBytes = measured.bytesInUse;
await measuringBroker.close();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: perRequestBytes - 1 });
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
await flush();
// The broker retained nothing: no token, no forward, no seen id.
expect(ledger.liveTokenCount).toBe(0);
expect(ledger.bytesInUse).toBe(0);
expect(harness.forwards.length).toBe(0);
// The refusal carries the fixed marker and is a bounded terminal response.
const refusal = harness.responses.find((frame) => frame.id === "req-1");
expect(refusal).toBeTruthy();
expect(refusal?.status).toBe(503);
expect(refusal?.body).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
// The refusal did not retain the id, so a resend after pressure eases is
// admitted: raise the ceiling and re-feed.
const roomyLedger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const roomyHarness = createFakeChannelHarness();
const roomyBroker = await createDuplexBridgeBroker({
channel: roomyHarness.channel,
forwardRequest: controllableForward(roomyHarness),
duplexAggregateByteLedger: roomyLedger,
});
brokers.push(roomyBroker);
roomyBroker.start();
roomyHarness.feed(requestFrame("req-1"));
await flush();
expect(roomyHarness.forwards.length).toBe(1);
});
it("releases every retained token on a terminal channel loss", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
harness.feed(requestFrame("req-2"));
// The reservation is synchronous, so both dispatches charge three tokens each
// at the request envelope, before the bodies reassemble.
expect(ledger.liveTokenCount).toBe(6);
// Let both requests reassemble and start their forwards, so the loss below
// transfers live forwards to the orphan registry.
await flush();
// The channel exits mid-flight. The broker latches the loss, transfers the
// in-flight forwards to the orphan registry, and releases the seen-id tokens.
harness.exit();
expect(broker.runDisposition.failed).toBe(true);
// The seen-id tokens released at loss; the two request tokens per forward stay
// charged until each aborted forward settles.
expect(ledger.liveTokenCount).toBe(4);
// The aborted forwards settle. Each finally owner releases its two request
// tokens, so the ledger returns to zero.
harness.forwards[0]?.reject(new Error("aborted"));
harness.forwards[1]?.reject(new Error("aborted"));
await flush();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("does not double-release a token when a forward settles after the channel closed", async () => {
const harness = createFakeChannelHarness();
let underflow = 0;
const ledger = new DuplexAggregateByteLedger({
ceilingBytes: 1_000_000,
telemetry: {
setBytesInUse() {},
recordReservationRejection() {},
recordAccountingUnderflow() {
underflow += 1;
},
},
});
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
// Let the request reassemble and start its forward before the close, so the
// close orphans a live forward that settles afterward.
await flush();
await broker.close();
// The close aborted the in-flight forward and orphaned it. The forward now
// settles after the close: its finally owner releases the request tokens once.
harness.forwards[0]?.reject(new Error("aborted"));
await flush();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
// No accounting defect: every token released exactly one time.
expect(underflow).toBe(0);
});
});
@@ -14,8 +14,10 @@ import {
type DuplexRequestFrame,
type DuplexResponseFrame,
} from "./duplex-frame-codec.js";
import { splitBodyIntoChunkFrames } from "./duplex-body-spool.js";
import {
createDuplexTelemetry,
DUPLEX_COUNTER_LOSS_TOTAL,
DUPLEX_SPAN_REQUEST,
type DuplexTelemetryCounterRecord,
type DuplexTelemetryEventRecord,
@@ -41,13 +43,27 @@ interface PendingForward {
settled: boolean;
}
/**
* One reassembled response the broker wrote back. The broker writes a response as
* one envelope frame that carries `bodyByteCount`, then the `body_chunk` frames
* that carry the body. The harness reassembles the body and exposes it as `body`,
* so a test asserts the response the same way it did with the one-frame model.
*/
type ReassembledResponse = DuplexResponseFrame & { body: string };
/** One request the harness feeds: the envelope frame plus its raw body text. */
interface RequestInput {
frame: DuplexRequestFrame;
bodyText: string;
}
/** The in-memory channel plus the levers a test uses to drive the broker. */
interface FakeChannelHarness {
channel: CommandManagedDuplexChannel;
/** Push one request frame into the broker read path. */
feed: (frame: DuplexRequestFrame) => void;
/** The response frames the broker wrote back, in order. */
responses: DuplexResponseFrame[];
/** Push one request (its envelope frame and its body_chunk frames) into the broker read path. */
feed: (input: RequestInput) => void;
/** The reassembled responses the broker wrote back, in order. */
responses: ReassembledResponse[];
/** Every forward call the broker made, in order. */
forwards: PendingForward[];
/** Resolve the newest unsettled forward for one id with a 200 result. */
@@ -56,36 +72,71 @@ interface FakeChannelHarness {
resolveAll: () => void;
}
/** Build one valid request frame with distinctive, secret-looking fields. */
function requestFrame(id: string, method = "POST"): DuplexRequestFrame {
/**
* Build one valid request with distinctive, secret-looking fields. The envelope
* carries `bodyByteCount`, and the harness rides the body on `body_chunk` frames.
*/
function requestFrame(
id: string,
method = "POST",
bodyText: string = JSON.stringify({ secret: "secret-request-body" }),
): RequestInput {
return {
version: DUPLEX_FRAME_VERSION,
type: "request",
id,
method,
path: `/api/issues/${id}`,
query: "?secret-query=leak",
headers: { authorization: "Bearer super-secret-provider-token" },
body: JSON.stringify({ secret: "secret-request-body" }),
frame: {
version: DUPLEX_FRAME_VERSION,
type: "request",
id,
method,
path: `/api/issues/${id}`,
query: "?secret-query=leak",
headers: { authorization: "Bearer super-secret-provider-token" },
bodyByteCount: Buffer.byteLength(bodyText, "utf8"),
},
bodyText,
};
}
/**
* Build the in-memory channel harness. The channel keeps the broker read
* listener, so `feed` pushes an encoded frame into the broker. The channel
* decodes each written frame, so `responses` holds the response frames only.
* listener, so `feed` pushes the encoded envelope and its body_chunk frames into
* the broker. The channel decodes each written frame and reassembles a response
* body, so `responses` holds one reassembled response per delivered request.
*/
function createFakeChannelHarness(): FakeChannelHarness {
const responses: DuplexResponseFrame[] = [];
const responses: ReassembledResponse[] = [];
const forwards: PendingForward[] = [];
const writtenDecoder = new DuplexFrameDecoder();
// The in-flight response reassembly, keyed by request id.
const responseAssembly = new Map<
string,
{ frame: DuplexResponseFrame; received: number; chunks: Buffer[] }
>();
let dataListener: ((chunk: string) => void) | null = null;
const channel: CommandManagedDuplexChannel = {
write: (data) => {
for (const result of writtenDecoder.push(data)) {
if (result.ok && result.frame.type === "response") {
responses.push(result.frame);
if (!result.ok) continue;
const frame = result.frame;
if (frame.type === "response") {
if (frame.bodyByteCount === 0) {
responses.push({ ...frame, body: "" });
} else {
responseAssembly.set(frame.id, { frame, received: 0, chunks: [] });
}
} else if (frame.type === "body_chunk") {
const assembly = responseAssembly.get(frame.id);
if (!assembly) continue;
const decoded = Buffer.from(frame.data, "base64");
assembly.received += decoded.length;
assembly.chunks.push(decoded);
if (assembly.received >= assembly.frame.bodyByteCount) {
responseAssembly.delete(frame.id);
responses.push({
...assembly.frame,
body: Buffer.concat(assembly.chunks).toString("utf8"),
});
}
}
}
},
@@ -99,9 +150,17 @@ function createFakeChannelHarness(): FakeChannelHarness {
return {
channel,
feed: (frame) => {
feed: ({ frame, bodyText }) => {
if (!dataListener) throw new Error("The broker did not bind the data listener.");
dataListener(encodeDuplexFrame(frame));
if (bodyText.length > 0) {
const chunks = splitBodyIntoChunkFrames(
frame.id,
Buffer.from(bodyText, "utf8"),
DUPLEX_FRAME_VERSION,
);
for (const chunk of chunks) dataListener(encodeDuplexFrame(chunk));
}
},
responses,
forwards,
@@ -133,6 +192,11 @@ function controllableForward(harness: FakeChannelHarness) {
});
}
/** Flush the microtasks and one macrotask, so the broker dispatches its reassembled forwards. */
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
/** The telemetry sink capture. It proves the broker records nothing for a refusal. */
interface TelemetryCapture {
spans: DuplexTelemetrySpanRecord[];
@@ -174,11 +238,11 @@ describe("duplex bridge broker request limits", () => {
expect(() => assertDuplexBrokerLimits({ maxInFlightRequests: 8, maxLifetimeRequests: 16 })).not.toThrow();
});
it("bounds the in-flight forwards and refuses the excess with a retryable terminal response", () => {
it("bounds the in-flight forwards and refuses the excess with a retryable terminal response", async () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
const maxInFlightRequests = 4;
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
telemetry,
@@ -188,10 +252,13 @@ describe("duplex bridge broker request limits", () => {
broker.start();
// Inject more than the limit of unique valid frames. Every forward hangs, so
// the broker holds the maximum number of in-flight forwards.
// the broker holds the maximum number of in-flight forwards. The broker gates
// the in-flight limit at the request envelope, so it refuses the excess at
// once; it dispatches each accepted forward after it reassembles the body.
const total = 10;
const ids = Array.from({ length: total }, (_unused, index) => `flight-${index}`);
for (const id of ids) harness.feed(requestFrame(id));
await flush();
// The broker forwarded only up to the limit. It refused the rest.
expect(harness.forwards).toHaveLength(maxInFlightRequests);
@@ -225,7 +292,7 @@ describe("duplex bridge broker request limits", () => {
it("frees capacity after a delivered request and forwards a resent refused id (no-replay preserved)", async () => {
const harness = createFakeChannelHarness();
const maxInFlightRequests = 2;
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
maxInFlightRequests,
@@ -233,30 +300,36 @@ describe("duplex bridge broker request limits", () => {
brokers.push(broker);
broker.start();
// Saturate the in-flight limit with two hung forwards.
// Saturate the in-flight limit with two hung forwards. The broker reserves the
// in-flight slot at the request envelope, then dispatches the forward after it
// reassembles the body.
harness.feed(requestFrame("keep-0"));
harness.feed(requestFrame("keep-1"));
await flush();
expect(harness.forwards).toHaveLength(2);
// A third unique id is refused, retryable. The broker did not forward it and
// did not retain its id.
harness.feed(requestFrame("resend-me"));
await flush();
expect(harness.forwards).toHaveLength(2);
expect(harness.responses).toHaveLength(1);
expect(JSON.parse(harness.responses[0].body).retryable).toBe(true);
// Free one in-flight slot. The delivered request drains the pending record.
harness.resolveForward("keep-0");
await Promise.resolve();
await flush();
// The gateway resends the refused id. Capacity is free now, so the broker
// forwards it exactly one time. This proves the refusal did not poison the id.
harness.feed(requestFrame("resend-me"));
await flush();
expect(harness.forwards.filter((forward) => forward.id === "resend-me")).toHaveLength(1);
// A resend of an already-forwarded id never reaches the forward twice.
const forwardedKeep1 = harness.forwards.filter((forward) => forward.id === "keep-1").length;
harness.feed(requestFrame("keep-1"));
await flush();
expect(harness.forwards.filter((forward) => forward.id === "keep-1")).toHaveLength(forwardedKeep1);
harness.resolveAll();
@@ -266,7 +339,7 @@ describe("duplex bridge broker request limits", () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
const maxLifetimeRequests = 3;
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
// Resolve every forward at once, so each dispatched id leaves the in-flight
// count but stays in the retained-id set.
@@ -284,7 +357,7 @@ describe("duplex bridge broker request limits", () => {
const total = 9;
for (let index = 0; index < total; index += 1) {
harness.feed(requestFrame(`life-${index}`));
await Promise.resolve();
await flush();
}
// The broker forwarded only up to the lifetime limit. The retained-id set
@@ -303,7 +376,7 @@ describe("duplex bridge broker request limits", () => {
// A resend of a refused id stays refused, so the set never grows.
harness.feed(requestFrame("life-8"));
await Promise.resolve();
await flush();
expect(harness.forwards).toHaveLength(maxLifetimeRequests);
// The telemetry surface stayed on the fixed request span for the delivered
@@ -319,9 +392,9 @@ describe("duplex bridge broker request limits", () => {
expect(broker.state).toBe("open");
});
it("forwards nothing and fails the channel closed under a flood of over-limit ids", () => {
it("forwards nothing and fails the channel closed under a flood of over-limit ids", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
@@ -346,9 +419,9 @@ describe("duplex bridge broker request limits", () => {
expect(broker.lossRecord?.reason).toBe("protocol_failure");
});
it("forwards a maximal-size id exactly once and does not forward a resend (no-replay preserved)", () => {
it("forwards a maximal-size id exactly once and does not forward a resend (no-replay preserved)", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
@@ -358,19 +431,21 @@ describe("duplex bridge broker request limits", () => {
// An id at the maximum byte size is allowed. The broker forwards it one time.
const maxId = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES);
harness.feed(requestFrame(maxId));
await flush();
expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1);
// Deliver the response, then resend the same id. The broker retained the id,
// so the resend never reaches the forward a second time.
harness.resolveForward(maxId);
harness.feed(requestFrame(maxId));
await flush();
expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1);
expect(broker.state).toBe("open");
});
it("maps a forward that resolves with the indeterminate marker to a non-retryable response and retains the id", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
@@ -383,6 +458,7 @@ describe("duplex bridge broker request limits", () => {
// non-retryable body, so the gateway maps it to a non-retryable status and a
// caller never repeats a possibly-committed mutation.
harness.feed(requestFrame("commit-1"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "commit-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
forward.settled = true;
@@ -422,7 +498,7 @@ describe("duplex bridge broker request limits", () => {
it("maps a forward rejection for a mutating method to a non-retryable indeterminate response and retains the id", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
@@ -435,6 +511,7 @@ describe("duplex bridge broker request limits", () => {
// forward, so a POST must return a non-retryable indeterminate response.
// A retryable status would let a caller repeat a committed mutation.
harness.feed(requestFrame("mutate-1", "POST"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "mutate-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
forward.settled = true;
@@ -461,9 +538,247 @@ describe("duplex bridge broker request limits", () => {
expect(broker.state).toBe("open");
});
it("substitutes a bounded terminal response for an oversized response and keeps the channel open", async () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
telemetry,
// A small frame bound makes a legitimately large response body exceed the
// bound, so the test exercises the encode guard without a megabyte body.
maxFrameBytes: 1000,
});
brokers.push(broker);
broker.start();
// Two requests are in flight at the same time. One resolves with a normal
// body; the other resolves with a body that pushes the response frame over
// the bound. The host produced both results, so both requests reached the
// host.
harness.feed(requestFrame("small-1", "POST"));
harness.feed(requestFrame("big-1", "POST"));
await flush();
const smallForward = harness.forwards.find((entry) => entry.id === "small-1" && !entry.settled);
if (!smallForward) throw new Error("The broker did not forward the small request.");
smallForward.settled = true;
smallForward.resolve({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
const bigForward = harness.forwards.find((entry) => entry.id === "big-1" && !entry.settled);
if (!bigForward) throw new Error("The broker did not forward the big request.");
bigForward.settled = true;
bigForward.resolve({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: "y".repeat(2000) }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
// The other in-flight request delivered its full response, unaffected by the
// oversized one.
expect(harness.responses).toHaveLength(2);
const delivered = harness.responses.find((entry) => entry.id === "small-1")!;
expect(delivered.status).toBe(200);
expect(delivered.outcome).toBe("completed");
expect(JSON.parse(delivered.body)).toEqual({ ok: true });
// The oversized response became one bounded terminal response marked
// indeterminate and non-retryable, so a caller does not retry a possible
// mutation. The broker did not write the oversized frame.
const terminal = harness.responses.find((entry) => entry.id === "big-1")!;
expect(terminal.status).toBe(502);
expect(terminal.outcome).toBe("indeterminate");
expect(terminal.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate");
expect(JSON.parse(terminal.body)).toEqual({
error: "upstream response too large to deliver",
outcome: "indeterminate",
retryable: false,
});
// The oversized body never crossed the wire. The broker did not truncate it or
// pass it through.
const writtenText = harness.responses.map((entry) => encodeDuplexFrame(entry)).join("");
expect(writtenText).not.toContain("yyyy");
// The broker recorded the oversized request as an error outcome, never a loss.
// The channel stays open and the run does not fail.
const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error");
expect(errorSpans).toHaveLength(1);
expect(errorSpans[0]!.name).toBe(DUPLEX_SPAN_REQUEST);
expect(broker.lossRecord).toBeNull();
expect(broker.state).toBe("open");
expect(broker.runDisposition.failed).toBe(false);
});
it("sends a minimal terminal response when the frame bound rejects the full replacement", async () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
// The bound accepts a compact request envelope (128 bytes) and the minimal
// terminal response envelope (114 bytes), but rejects the full replacement
// response envelope (193 bytes). This proves the broker never drops the channel
// or strands the request when the bound is smaller than the full replacement.
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
telemetry,
maxFrameBytes: 150,
});
brokers.push(broker);
broker.start();
// A compact request envelope with an empty body fits the bound, so the broker
// forwards it.
harness.feed({
frame: {
version: DUPLEX_FRAME_VERSION,
type: "request",
id: "big-1",
method: "POST",
path: "/api/issues/big-1",
query: "",
headers: {},
bodyByteCount: 0,
},
bodyText: "",
});
await flush();
const forward = harness.forwards.find((entry) => entry.id === "big-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
forward.settled = true;
// The host produced a large response body that pushes the real response frame
// and the full replacement frame over the bound.
forward.resolve({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: "y".repeat(2000) }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
// The broker sent one minimal terminal response, not the oversized real
// response and not the full replacement. The minimal response carries the
// indeterminate outcome, so the gateway maps it to a terminal 409.
expect(harness.responses).toHaveLength(1);
const terminal = harness.responses[0]!;
expect(terminal.id).toBe("big-1");
expect(terminal.status).toBe(502);
expect(terminal.outcome).toBe("indeterminate");
expect(terminal.headers).toEqual({});
expect(terminal.body).toBe("");
// The oversized body never crossed the wire.
const writtenText = harness.responses.map((entry) => encodeDuplexFrame(entry)).join("");
expect(writtenText).not.toContain("yyyy");
// The broker recorded the request as an error outcome, never a loss. The
// channel stays open and the run does not fail.
const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error");
expect(errorSpans).toHaveLength(1);
expect(errorSpans[0]!.name).toBe(DUPLEX_SPAN_REQUEST);
expect(broker.lossRecord).toBeNull();
expect(broker.state).toBe("open");
expect(broker.runDisposition.failed).toBe(false);
});
it("keeps the channel open and logs when the bound rejects even the minimal terminal response", async () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
const logs: string[] = [];
// The bound accepts a tiny request envelope (107 bytes) but rejects even the
// minimal terminal response envelope (110 bytes). The broker cannot send any
// frame for this request, so it must keep the channel open and log a clear
// local error.
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
telemetry,
maxFrameBytes: 109,
logger: (message) => logs.push(message),
});
brokers.push(broker);
broker.start();
harness.feed({
frame: {
version: DUPLEX_FRAME_VERSION,
type: "request",
id: "x",
method: "GET",
path: "/",
query: "",
headers: {},
bodyByteCount: 0,
},
bodyText: "",
});
await flush();
const forward = harness.forwards.find((entry) => entry.id === "x" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
forward.settled = true;
forward.resolve({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: "y".repeat(200) }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
// The broker sent no response frame, because no frame fits the bound.
expect(harness.responses).toHaveLength(0);
// The broker recorded the request as an error outcome, never a loss.
const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error");
expect(errorSpans).toHaveLength(1);
// The broker logged a clear local error and kept the channel open.
expect(logs.some((line) => line.includes("could not deliver a terminal response"))).toBe(true);
expect(broker.lossRecord).toBeNull();
expect(broker.state).toBe("open");
expect(broker.runDisposition.failed).toBe(false);
});
it("completes a request within the bound and delivers the real response", async () => {
const harness = createFakeChannelHarness();
const { telemetry, capture } = createTelemetryCapture();
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
telemetry,
maxFrameBytes: 1000,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("ok-1", "POST"));
await flush();
harness.resolveForward("ok-1", JSON.stringify({ ok: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
// The broker delivered the real response promptly with the completed outcome.
expect(harness.responses).toHaveLength(1);
const delivered = harness.responses[0]!;
expect(delivered.id).toBe("ok-1");
expect(delivered.status).toBe(200);
expect(delivered.outcome).toBe("completed");
expect(JSON.parse(delivered.body)).toEqual({ ok: true });
// The delivered response records an `ok` span, never an error, and never a loss.
const okSpans = capture.spans.filter((span) => span.dimensions.outcome === "ok");
expect(okSpans).toHaveLength(1);
const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error");
expect(errorSpans).toHaveLength(0);
expect(broker.lossRecord).toBeNull();
expect(broker.state).toBe("open");
expect(broker.runDisposition.failed).toBe(false);
});
it("maps a forward rejection for a safe method to a retryable response", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
});
@@ -474,6 +789,7 @@ describe("duplex bridge broker request limits", () => {
// mutation. A forward rejection for a GET stays retryable: the broker
// returns a 502 with the completed outcome, so the gateway passes it through.
harness.feed(requestFrame("read-1", "GET"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "read-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
forward.settled = true;
@@ -492,7 +808,7 @@ describe("duplex bridge broker request limits", () => {
it("maps a forward-budget timeout for a safe method to a retryable response", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
// A tiny forward budget aborts the controller before the manual rejection.
@@ -505,6 +821,7 @@ describe("duplex bridge broker request limits", () => {
// stay retryable: the broker returns a 504 with the completed outcome and no
// indeterminate marker, so the gateway does not map it to a terminal 409.
harness.feed(requestFrame("read-timeout-1", "GET"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "read-timeout-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
@@ -528,7 +845,7 @@ describe("duplex bridge broker request limits", () => {
it("maps a forward-budget timeout for a mutating method to a non-retryable indeterminate response", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
budgets: { forwardTimeoutMs: 5 },
@@ -540,6 +857,7 @@ describe("duplex bridge broker request limits", () => {
// must keep the terminal contract: a 504 with the indeterminate outcome, so
// the gateway maps it to a non-retryable 409 and no caller double-applies it.
harness.feed(requestFrame("mutate-timeout-1", "POST"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "mutate-timeout-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
@@ -562,7 +880,7 @@ describe("duplex bridge broker request limits", () => {
it("maps a response-budget backstop for a safe method to a retryable response", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
// The forward budget aborts the call, but the forward promise stays
@@ -579,6 +897,7 @@ describe("duplex bridge broker request limits", () => {
// retryable: a 504 with the completed outcome and no indeterminate marker,
// so the gateway does not map it to a terminal 409.
harness.feed(requestFrame("read-stall-1", "GET"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "read-stall-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
@@ -599,7 +918,7 @@ describe("duplex bridge broker request limits", () => {
it("maps a response-budget backstop for a mutating method to a non-retryable indeterminate response", async () => {
const harness = createFakeChannelHarness();
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
budgets: { forwardTimeoutMs: 5, responseBudgetMs: 15, gatewayWaitMs: 40 },
@@ -612,6 +931,7 @@ describe("duplex bridge broker request limits", () => {
// contract: a 504 with the indeterminate outcome, so the gateway maps it to a
// non-retryable 409 and no caller double-applies it.
harness.feed(requestFrame("mutate-stall-1", "POST"));
await flush();
const forward = harness.forwards.find((entry) => entry.id === "mutate-stall-1" && !entry.settled);
if (!forward) throw new Error("The broker did not forward the request.");
@@ -629,3 +949,91 @@ describe("duplex bridge broker request limits", () => {
});
});
});
/**
* A channel harness that captures the broker exit listener. `emitExit` invokes it,
* so a test drives one channel exit into the broker.
*/
function createExitChannelHarness(): {
channel: CommandManagedDuplexChannel;
emitExit: (exit: { exitCode: number | null; transportClosed?: boolean }) => void;
} {
let exitListener:
| ((exit: { exitCode: number | null; transportClosed?: boolean }) => void)
| null = null;
const channel: CommandManagedDuplexChannel = {
write: () => undefined,
onData: () => undefined,
onExit: (listener) => {
exitListener = listener;
},
stop: () => undefined,
close: () => Promise.resolve(),
};
return {
channel,
emitExit: (exit) => {
if (!exitListener) throw new Error("The broker did not bind the exit listener.");
exitListener(exit);
},
};
}
describe("duplex bridge broker exit taxonomy", () => {
const brokers: DuplexBridgeBroker[] = [];
afterEach(async () => {
while (brokers.length > 0) {
const broker = brokers.pop();
if (broker) await broker.close();
}
});
it("records a transport close as a distinct loss, not a process exit", async () => {
const { telemetry, capture } = createTelemetryCapture();
const harness = createExitChannelHarness();
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: () =>
Promise.resolve({ status: 200, headers: {}, body: "" }),
telemetry,
});
brokers.push(broker);
broker.start();
// A reason-less transport close carries the discriminator and no exit code.
harness.emitExit({ exitCode: null, transportClosed: true });
expect(broker.lossRecord?.reason).toBe("transport_closed");
expect(broker.runDisposition).toEqual({ failed: true, lossReason: "transport_closed" });
const lossCounter = capture.counters.find(
(record) => record.metric === DUPLEX_COUNTER_LOSS_TOTAL,
);
expect(lossCounter?.dimensions.loss_reason).toBe("transport_closed");
});
it("records a numeric exit as a process exit", async () => {
const { telemetry, capture } = createTelemetryCapture();
const harness = createExitChannelHarness();
const broker = await createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: () =>
Promise.resolve({ status: 200, headers: {}, body: "" }),
telemetry,
});
brokers.push(broker);
broker.start();
// A numeric exit code is a real process exit, so the broker keeps the existing
// channel_exit -> provider_exit mapping.
harness.emitExit({ exitCode: 0 });
expect(broker.lossRecord?.reason).toBe("channel_exit");
expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" });
const lossCounter = capture.counters.find(
(record) => record.metric === DUPLEX_COUNTER_LOSS_TOTAL,
);
expect(lossCounter?.dimensions.loss_reason).toBe("provider_exit");
});
});
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ import net from "node:net";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import { afterEach, describe, expect, it } from "vitest";
@@ -14,6 +15,9 @@ import {
} from "./sandbox-callback-bridge.js";
import {
DuplexFrameDecoder,
DUPLEX_BODY_CHUNK_RAW_BYTES,
DUPLEX_FRAME_VERSION,
encodeDuplexFrame,
type DuplexFrame,
type DuplexReadyFrame,
type DuplexRequestFrame,
@@ -23,6 +27,7 @@ import {
type DuplexBridgeBroker,
type DuplexBrokerForwardResult,
} from "./duplex-bridge-broker.js";
import type { ReassembledBody } from "./duplex-body-spool.js";
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
/**
@@ -299,7 +304,7 @@ async function createHarness(options: HarnessOptions = {}): Promise<DuplexE2EHar
const forwardRequest = async (
request: DuplexRequestFrame,
opts: { signal: AbortSignal },
opts: { signal: AbortSignal; body: ReassembledBody },
): Promise<DuplexBrokerForwardResult> => {
forwardedRequests.push(request);
// Keep the real route allowlist on the forward seam. The broker forwards
@@ -334,12 +339,16 @@ async function createHarness(options: HarnessOptions = {}): Promise<DuplexE2EHar
headers.set("authorization", `Bearer ${hostApiToken}`);
headers.set("x-paperclip-run-id", runId);
const target = new URL(`${request.path}${request.query ?? ""}`, api.origin);
const response = await fetch(target, {
method,
headers,
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
signal: opts.signal,
});
// Stream the reassembled request body to the host, the same as the real
// forward. A streamed body needs `duplex: "half"`.
const forwardInit: RequestInit & { duplex?: "half" } = { method, headers, signal: opts.signal };
if (method !== "GET" && method !== "HEAD") {
forwardInit.body = Readable.toWeb(
opts.body.createReadStream(),
) as unknown as ReadableStream<Uint8Array>;
forwardInit.duplex = "half";
}
const response = await fetch(target, forwardInit);
const body = await response.text();
const outHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
@@ -349,7 +358,7 @@ async function createHarness(options: HarnessOptions = {}): Promise<DuplexE2EHar
return { status: response.status, headers: outHeaders, body };
};
const broker = createDuplexBridgeBroker({
const broker = await createDuplexBridgeBroker({
channel,
forwardRequest,
logger: () => undefined,
@@ -456,7 +465,7 @@ describe("duplex bridge local end-to-end harness", () => {
const ready = harness.observedFrames.find(
(frame) => frame.type === "ready",
) as DuplexReadyFrame;
expect(ready.version).toBe(1);
expect(ready.version).toBe(2);
expect(ready.nonce).toBe(harness.nonce);
// READY is liveness only. It carries no address data.
expect((ready as unknown as Record<string, unknown>).address).toBeUndefined();
@@ -643,4 +652,90 @@ describe("duplex bridge local end-to-end harness", () => {
expect(harness.api.listening()).toBe(false);
expect(harness.api.openSocketCount()).toBe(0);
}, 20000);
it("fails a request with a local 502 when the host sends a malformed response chunk", async () => {
const RAW = DUPLEX_BODY_CHUNK_RAW_BYTES;
// Each case declares one malformed body_chunk a broken or hostile host could
// send. The gateway must reject it at once with a local 502. It must not grow
// its response reassembly buffer until the response timeout. `bodyByteCount`
// sets the declared body size on the response envelope. `data` is the base64
// payload of the one injected chunk.
const cases: Array<{ name: string; bodyByteCount: number; data: string; error: string }> = [
{ name: "an empty chunk", bodyByteCount: RAW, data: "", error: "duplex response body_chunk is empty" },
{
name: "an undersized non-final chunk",
bodyByteCount: RAW + 8,
data: Buffer.alloc(4, 1).toString("base64"),
error: "duplex response body_chunk has the wrong size",
},
{
name: "an oversized chunk",
bodyByteCount: RAW + 8,
data: Buffer.alloc(RAW + 4, 1).toString("base64"),
error: "duplex response body_chunk has the wrong size",
},
{
name: "an overrun past the declared size",
bodyByteCount: 10,
data: Buffer.alloc(200, 1).toString("base64"),
error: "duplex response body overruns the declared size",
},
{
name: "a non-canonical base64 chunk",
bodyByteCount: 10,
data: "AB==",
error: "duplex response body_chunk is not canonical base64",
},
];
const harness = await createHarness();
harnesses.push(harness);
await harness.waitFor(readyPredicate(harness), "the gateway to become ready");
// Hold the broker forward open, so only the injected frames answer each
// request. The gateway reassembles the response body itself, so a raw
// malformed chunk on its stdin drives the reject path under test.
harness.setForwardMode("hang");
for (const testCase of cases) {
const forwardedBefore = harness.forwardedRequests.length;
const pendingFetch = fetch(`${harness.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${harness.bridgeToken}` },
});
void pendingFetch.catch(() => undefined);
await harness.waitFor(
() => harness.forwardedRequests.length > forwardedBefore,
`the broker to receive the forwarded request for ${testCase.name}`,
);
const id = harness.forwardedRequests[harness.forwardedRequests.length - 1].id;
// Inject the response envelope, then the one malformed body_chunk, straight
// to the gateway stdin. This bypasses the broker response encoder, so the
// gateway sees the exact bytes a broken or hostile host could send.
harness.child.stdin.write(
encodeDuplexFrame({
version: DUPLEX_FRAME_VERSION,
type: "response",
id,
status: 200,
headers: {},
bodyByteCount: testCase.bodyByteCount,
outcome: "completed",
}),
);
harness.child.stdin.write(
encodeDuplexFrame({
version: DUPLEX_FRAME_VERSION,
type: "body_chunk",
id,
seq: 0,
data: testCase.data,
}),
);
const response = await pendingFetch;
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({ error: testCase.error });
}
}, 20000);
});
@@ -8,9 +8,11 @@ import {
DuplexFrameDecoder,
decodeDuplexLine,
encodeDuplexFrame,
encodeDuplexFrameChecked,
type DuplexDecodeResult,
type DuplexFrame,
} from "./duplex-frame-codec.js";
import { DuplexAggregateByteLedger } from "./duplex-aggregate-byte-ledger.js";
// One expected decode result in the fixture: either a decoded frame or the code
// of a protocol error. The codec must never throw on the read path.
@@ -33,10 +35,20 @@ interface Vector {
expected: ExpectedResult[];
}
// One encode-bound vector: a frame, the size limit, and the expected checked
// encode result. An ok result must also decode back to the same frame.
interface EncodeVector {
name: string;
maxFrameBytes: number;
frame: DuplexFrame;
expected: { ok: true } | { ok: false; error: string };
}
interface Fixture {
frameVersion: number;
defaultMaxFrameBytes: number;
vectors: Vector[];
encodeVectors: EncodeVector[];
}
const fixturePath = fileURLToPath(
@@ -114,7 +126,7 @@ describe("round-trip", () => {
),
),
);
for (const type of ["request", "response", "ready", "heartbeat", "close", "error"]) {
for (const type of ["request", "response", "body_chunk", "ready", "heartbeat", "close", "error"]) {
expect(types).toContain(type);
}
});
@@ -200,8 +212,8 @@ describe("streaming decoder behavior", () => {
method: "POST",
path: "/x",
query: "",
headers: {},
body: "😀",
headers: { "x-note": "😀" },
bodyByteCount: 0,
};
const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8");
// Split inside the four-byte emoji, right before a continuation byte.
@@ -263,7 +275,7 @@ describe("request id byte bound", () => {
path: "/",
query: "",
headers: {},
body: "",
bodyByteCount: 0,
});
}
@@ -302,7 +314,7 @@ describe("request id byte bound", () => {
id,
status: 200,
headers: {},
body: "",
bodyByteCount: 0,
outcome: "completed",
}),
);
@@ -310,3 +322,186 @@ describe("request id byte bound", () => {
if (!result.ok) expect(result.error.code).toBe("id_too_large");
});
});
describe("decoder aggregate byte ledger charging", () => {
// The host injects the process-owned ledger into the decoder. The decoder
// charges the raw partial-frame bytes it retains between chunks, plus the peak
// replacement buffer it allocates on concat. It never retains an uncharged
// buffer, and it fails closed when a reservation would pass the ceiling.
function makeLedger(ceilingBytes = 1024): DuplexAggregateByteLedger {
return new DuplexAggregateByteLedger({ ceilingBytes });
}
it("charges the retained partial frame, then releases it when the frame completes", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const bytes = Buffer.from(line, "utf8");
// A partial frame with no newline stays retained and stays charged.
const first = decoder.push(bytes.subarray(0, 3));
expect(first).toHaveLength(0);
expect(ledger.bytesInUse).toBe(3);
expect(ledger.liveTokenCount).toBe(1);
// The completing chunk delivers the frame and releases the retained bytes.
const second = decoder.push(bytes.subarray(3));
expect(second).toHaveLength(1);
expect(second[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("covers the peak concat buffer and settles on the remaining bytes", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const first = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const partial = '{"version":1,"type":"hea';
// One full frame plus a partial second frame arrive in one chunk. The full
// frame decodes and releases; the partial tail stays charged at its exact size.
const results = decoder.push(Buffer.from(first + partial, "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(Buffer.byteLength(partial, "utf8"));
expect(ledger.liveTokenCount).toBe(1);
});
it("fails closed when a chunk would pass the ceiling and retains nothing", () => {
const ledger = makeLedger(16);
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const results = decoder.push(Buffer.from("z".repeat(64), "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) {
expect(results[0].error.code).toBe("aggregate_bytes_exceeded");
}
// The decoder retained nothing, so the ledger stays at zero.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("resynchronizes on the next newline after an aggregate rejection", () => {
const ledger = makeLedger(64);
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
// A first chunk over the ceiling fails closed.
const rejected = decoder.push(Buffer.from("z".repeat(128), "utf8"));
expect(rejected[0].ok).toBe(false);
// The next chunk starts with a newline, so the decoder drops the stale tail
// and decodes the good frame that follows.
const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const results = decoder.push(Buffer.from(`\n${good}`, "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("releases the retained token when an oversized frame is discarded", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ maxFrameBytes: 16, aggregateByteLedger: ledger });
// An oversized frame with no newline is discarded and its bytes are released.
const results = decoder.push(Buffer.from("z".repeat(64), "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large");
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("releases the retained partial frame on dispose", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
decoder.push(Buffer.from('{"version":1', "utf8"));
expect(ledger.bytesInUse).toBeGreaterThan(0);
decoder.dispose();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
// A second dispose is a no-op and records no accounting defect.
decoder.dispose();
expect(ledger.bytesInUse).toBe(0);
});
it("charges nothing when the caller injects no ledger", () => {
const decoder = new DuplexFrameDecoder();
const results = decoder.push(Buffer.from('{"version":1', "utf8"));
expect(results).toHaveLength(0);
// No ledger means no charge; the decoder still buffers and decodes as before.
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const done = decoder.push(Buffer.from(`}\n${line}`, "utf8"));
// The stitched first frame is malformed JSON; the second is a valid heartbeat.
expect(done.some((result) => result.ok)).toBe(true);
});
});
describe("size-checked encode", () => {
it("runs every shared encode vector and matches the expected result", () => {
// Every codec copy runs the same encode vectors. This copy proves it enforces
// the same bound the embedded gateway copy enforces.
for (const vector of fixture.encodeVectors) {
const result = encodeDuplexFrameChecked(vector.frame, vector.maxFrameBytes);
expect(result.ok).toBe(vector.expected.ok);
if (result.ok) {
// An ok line ends with one newline and decodes back to the same frame, so
// the encode guard never truncates or passes a bad frame through.
expect(result.line.endsWith("\n")).toBe(true);
expect(result.line.slice(0, -1)).not.toContain("\n");
const decoded = decodeDuplexLine(result.line.slice(0, -1));
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.frame).toEqual(vector.frame);
} else if (!vector.expected.ok) {
expect(result.error.code).toBe(vector.expected.error);
}
}
});
it("rejects an over-bound frame with a typed outcome and never throws", () => {
const frame: DuplexFrame = {
version: DUPLEX_FRAME_VERSION,
type: "response",
id: "big",
status: 200,
headers: { "x-pad": "x".repeat(2_000) },
bodyByteCount: 0,
outcome: "completed",
};
expect(() => encodeDuplexFrameChecked(frame, 1_000)).not.toThrow();
const result = encodeDuplexFrameChecked(frame, 1_000);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe("frame_too_large");
});
it("measures the bound in bytes without the trailing newline, so the boundary is inclusive", () => {
// Build a frame whose encoded JSON is exactly N bytes. The guard measures the
// JSON without the newline, so N bytes encodes and N+1 bytes fails. This is the
// same boundary the decoder applies, so encode and decode agree exactly.
// Pad a header value to grow the encoded JSON by an exact byte count. The
// "x-pad" value adds one byte per character, so the frame lands on an exact
// size the guard measures without the trailing newline.
const pad = (n: number): DuplexFrame => ({
version: DUPLEX_FRAME_VERSION,
type: "response",
id: "boundary",
status: 200,
headers: { "x-pad": "x".repeat(n) },
bodyByteCount: 0,
outcome: "completed",
});
const baseBytes = Buffer.byteLength(JSON.stringify(pad(0)), "utf8");
const limit = baseBytes + 10;
const atLimit = pad(10);
expect(Buffer.byteLength(JSON.stringify(atLimit), "utf8")).toBe(limit);
const okResult = encodeDuplexFrameChecked(atLimit, limit);
expect(okResult.ok).toBe(true);
const overResult = encodeDuplexFrameChecked(pad(11), limit);
expect(overResult.ok).toBe(false);
if (!overResult.ok) expect(overResult.error.code).toBe("frame_too_large");
});
it("defaults the bound to the default max frame bytes", () => {
const frame: DuplexFrame = { version: DUPLEX_FRAME_VERSION, type: "heartbeat" };
const result = encodeDuplexFrameChecked(frame);
expect(result.ok).toBe(true);
if (result.ok) expect(result.line).toBe(encodeDuplexFrame(frame));
});
});
@@ -17,8 +17,22 @@
* keeps one bad frame from crashing the read loop.
*/
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type ReservationToken,
} from "./duplex-aggregate-byte-ledger.js";
/** The wire version this codec reads and writes. */
export const DUPLEX_FRAME_VERSION = 1;
export const DUPLEX_FRAME_VERSION = 2;
/**
* The fixed raw byte size of one body slice. The sender splits a body into
* `body_chunk` frames of this raw size, except the final chunk, which holds the
* remaining bytes. The base64 text of one full slice is about 349 KiB, so each
* encoded `body_chunk` frame stays well under {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}.
*/
export const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024;
/**
* The default maximum size of one frame, in bytes. The decoder rejects a longer
@@ -48,6 +62,7 @@ const EMPTY = Buffer.alloc(0);
export const DUPLEX_FRAME_TYPES = {
request: "request",
response: "response",
body_chunk: "body_chunk",
ready: "ready",
heartbeat: "heartbeat",
close: "close",
@@ -57,7 +72,12 @@ export const DUPLEX_FRAME_TYPES = {
/** The outcome of a response frame. A loss response carries a non-completed outcome. */
export type DuplexResponseOutcome = "completed" | "indeterminate" | "unavailable";
/** A request frame. The gateway forwards it to the host API path. */
/**
* A request envelope frame. The gateway forwards it to the host API path. The
* envelope carries `bodyByteCount`, the raw byte length of the request body. The
* body itself rides one or more `body_chunk` frames that share the same `id`. A
* `bodyByteCount` of 0 means an empty body and no `body_chunk` frame follows.
*/
export interface DuplexRequestFrame {
version: number;
type: "request";
@@ -66,20 +86,43 @@ export interface DuplexRequestFrame {
path: string;
query: string;
headers: Record<string, string>;
body: string;
bodyByteCount: number;
}
/** A response frame. The host returns it for one request id. */
/**
* A response envelope frame. The host returns it for one request id. The
* envelope carries `bodyByteCount`, the raw byte length of the response body.
* The body itself rides one or more `body_chunk` frames that share the same
* `id`. A `bodyByteCount` of 0 means an empty body and no `body_chunk` frame
* follows.
*/
export interface DuplexResponseFrame {
version: number;
type: "response";
id: string;
status: number;
headers: Record<string, string>;
body: string;
bodyByteCount: number;
outcome: DuplexResponseOutcome;
}
/**
* A body-chunk frame. One frame carries one raw body slice as base64 text in
* `data`. The frames for one body share the `id` of the envelope. `seq` starts
* at 0 and increases by one for each next chunk, with no gap and no repeat. Each
* raw slice is {@link DUPLEX_BODY_CHUNK_RAW_BYTES} bytes, except the final slice,
* which holds the remaining bytes. The receiver reassembles the slices in `seq`
* order and stops when the total raw byte count equals the envelope
* `bodyByteCount`.
*/
export interface DuplexBodyChunkFrame {
version: number;
type: "body_chunk";
id: string;
seq: number;
data: string;
}
/**
* The READY control frame. The gateway sends it one time after it binds the
* host-assigned listener port. READY is a liveness signal, not an address
@@ -123,6 +166,7 @@ export interface DuplexErrorFrame {
export type DuplexFrame =
| DuplexRequestFrame
| DuplexResponseFrame
| DuplexBodyChunkFrame
| DuplexReadyFrame
| DuplexHeartbeatFrame
| DuplexCloseFrame
@@ -134,7 +178,8 @@ export type DuplexProtocolErrorCode =
| "unknown_type"
| "version_mismatch"
| "frame_too_large"
| "id_too_large";
| "id_too_large"
| "aggregate_bytes_exceeded";
/** A decode-time protocol error. The read path returns it; it never throws. */
export interface DuplexProtocolError {
@@ -147,6 +192,15 @@ export type DuplexDecodeResult =
| { ok: true; frame: DuplexFrame }
| { ok: false; error: DuplexProtocolError };
/**
* One size-checked encode result: one line, or a `frame_too_large` error. The
* shape mirrors {@link DuplexDecodeResult}, so the encode side reports the
* over-limit case as a typed outcome, not an exception.
*/
export type DuplexEncodeResult =
| { ok: true; line: string }
| { ok: false; error: { code: "frame_too_large"; message: string } };
const RESPONSE_OUTCOMES: ReadonlySet<string> = new Set<DuplexResponseOutcome>([
"completed",
"indeterminate",
@@ -178,6 +232,11 @@ function idWithinLimit(id: string): boolean {
return Buffer.byteLength(id, "utf8") <= DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES;
}
/** Return true when the value is a safe integer that is zero or positive. */
function isSafeNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
/**
* Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify`
* escapes any newline inside a string value, so the returned line holds no
@@ -187,6 +246,30 @@ export function encodeDuplexFrame(frame: DuplexFrame): string {
return `${JSON.stringify(frame)}\n`;
}
/**
* Encode one frame to a single line and enforce the maximum frame size. The
* function measures the encoded JSON in bytes, without the trailing newline, so
* it matches the decoder bound exactly: a line the decoder accepts encodes, and a
* line the decoder rejects returns a `frame_too_large` result. The function never
* throws; it reports the over-limit case as a typed outcome.
*
* The size bound follows request and response bodies the host does not control,
* so an over-limit frame is an expected condition, not a programming error. Every
* write path that can carry a large body uses this function, so no path emits a
* frame the peer decoder rejects. The bound applies to every frame type; the
* guard is a no-op for a small control frame.
*/
export function encodeDuplexFrameChecked(
frame: DuplexFrame,
maxFrameBytes: number = DEFAULT_MAX_DUPLEX_FRAME_BYTES,
): DuplexEncodeResult {
const json = JSON.stringify(frame);
if (Buffer.byteLength(json, "utf8") > maxFrameBytes) {
return { ok: false, error: { code: "frame_too_large", message: "frame exceeds the maximum size" } };
}
return { ok: true, line: `${json}\n` };
}
/**
* Decode one line (no trailing newline) to a frame or a protocol error. The
* streaming decoder calls this for each complete line. It is exported so a
@@ -221,6 +304,8 @@ function validateFrame(frame: Record<string, unknown>): DuplexDecodeResult {
return validateRequest(frame);
case "response":
return validateResponse(frame);
case "body_chunk":
return validateBodyChunk(frame);
case "ready":
return validateReady(frame);
case "heartbeat":
@@ -240,7 +325,7 @@ function validateRequest(frame: Record<string, unknown>): DuplexDecodeResult {
typeof frame.method !== "string" ||
typeof frame.path !== "string" ||
typeof frame.query !== "string" ||
typeof frame.body !== "string" ||
!isSafeNonNegativeInteger(frame.bodyByteCount) ||
!isStringRecord(frame.headers)
) {
return fail("malformed_frame", "request frame has a missing or wrong-typed field");
@@ -258,7 +343,7 @@ function validateResponse(frame: Record<string, unknown>): DuplexDecodeResult {
if (
typeof frame.id !== "string" ||
typeof frame.status !== "number" ||
typeof frame.body !== "string" ||
!isSafeNonNegativeInteger(frame.bodyByteCount) ||
!isStringRecord(frame.headers) ||
typeof frame.outcome !== "string" ||
!RESPONSE_OUTCOMES.has(frame.outcome)
@@ -273,6 +358,29 @@ function validateResponse(frame: Record<string, unknown>): DuplexDecodeResult {
return ok(frame as unknown as DuplexResponseFrame);
}
/**
* Validate a `body_chunk` frame at the structural level. The codec is stateless,
* so it checks only the per-frame shape here:
* - `id` is a string within {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}.
* - `seq` is a safe integer that is zero or positive.
* - `data` is a string.
* The receive-side reassembler owns the stateful checks: the base64 canonical
* form, the fixed slice size, the `seq` order, and the total against
* `bodyByteCount`. The codec never decodes `data` here.
*/
function validateBodyChunk(frame: Record<string, unknown>): DuplexDecodeResult {
if (typeof frame.id !== "string" || typeof frame.data !== "string") {
return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed field");
}
if (!isSafeNonNegativeInteger(frame.seq)) {
return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq");
}
if (!idWithinLimit(frame.id)) {
return fail("id_too_large", "body_chunk frame id exceeds the maximum size");
}
return ok(frame as unknown as DuplexBodyChunkFrame);
}
function validateReady(frame: Record<string, unknown>): DuplexDecodeResult {
// READY carries a liveness nonce, not an address. The schema is strict: a valid
// READY frame holds exactly `version`, `type`, and `nonce`. The decoder rejects
@@ -311,6 +419,18 @@ function validateError(frame: Record<string, unknown>): DuplexDecodeResult {
export interface DuplexFrameDecoderOptions {
/** The maximum size of one frame, in bytes. Defaults to {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. */
maxFrameBytes?: number;
/**
* The process-owned aggregate byte ledger. When present, the decoder reserves
* the exact bytes of the raw partial frame it retains between chunks, and the
* peak replacement buffer it allocates on concat, against this ledger under the
* `decoder_buffer` owner. The host injects the same ledger object it injects at
* every other retention site, so one gauge bounds the aggregate retained bytes.
* When absent the decoder charges nothing and behaves as before.
*
* The generated sandbox decoder runs in a separate operating-system process. It
* cannot share this host ledger. It receives its own separate cap instead.
*/
aggregateByteLedger?: DuplexAggregateByteLedger;
}
/**
@@ -331,15 +451,41 @@ export class DuplexFrameDecoder {
private buffer: Buffer = EMPTY;
private discarding = false;
private readonly maxFrameBytes: number;
private readonly ledger: DuplexAggregateByteLedger | null;
/**
* The token for the bytes currently retained in `this.buffer`. Its byte amount
* equals `this.buffer.length` at the end of every `push`. It is `null` when the
* buffer is empty or the decoder has no ledger.
*/
private retainedToken: ReservationToken | null = null;
constructor(options: DuplexFrameDecoderOptions = {}) {
this.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES;
this.ledger = options.aggregateByteLedger ?? null;
}
/** Feed one chunk. Return the frames and protocol errors that complete on it. */
push(chunk: Buffer | Uint8Array | string): DuplexDecodeResult[] {
const incoming =
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
// Reserve the incoming bytes before the concat allocates the replacement
// buffer. The retained token already covers the current buffer, so the two
// tokens together cover the peak `old + incoming` allocation. A rejected
// reservation fails closed: the decoder drops the incoming chunk, releases
// the retained buffer, resynchronizes at the next newline, and reports the
// fixed aggregate rejection marker. It retains nothing uncharged.
let incomingToken: ReservationToken | null = null;
if (this.ledger && incoming.length > 0) {
incomingToken = this.ledger.reserve("decoder_buffer", incoming.length);
if (incomingToken === null) {
this.releaseRetained();
this.buffer = EMPTY;
this.discarding = true;
return [fail("aggregate_bytes_exceeded", DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED)];
}
}
this.buffer =
this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
@@ -378,6 +524,54 @@ export class DuplexFrameDecoder {
}
results.push(decodeDuplexLine(line));
}
// Reconcile the ledger to the bytes still retained in `this.buffer`. Release
// the old retained token and the incoming token, then reserve one token for
// the remainder. The remainder is a subset of the just-released
// `old + incoming` bytes, so this reserve always fits.
this.reconcileRetained(incomingToken);
return results;
}
/**
* Release the retained-buffer token and drop the buffer. The host calls this at
* channel teardown, so the decoder never leaks a `decoder_buffer` token after
* the channel ends. A second call is a no-op, because the field is already
* `null`.
*/
dispose(): void {
this.releaseRetained();
this.buffer = EMPTY;
this.discarding = false;
}
/** Release the retained-buffer token one time and clear the field. */
private releaseRetained(): void {
if (this.ledger && this.retainedToken) {
this.ledger.release(this.retainedToken);
}
this.retainedToken = null;
}
/**
* Move the ledger charge to the bytes still in `this.buffer`. Release the old
* retained token and the `incoming` token, then reserve one token for the
* remaining bytes. A `null` reserve here is an accounting defect, because the
* remainder never passes the just-released bytes; the decoder fails closed and
* drops the buffer so it retains nothing uncharged.
*/
private reconcileRetained(incomingToken: ReservationToken | null): void {
if (!this.ledger) return;
this.releaseRetained();
if (incomingToken) {
this.ledger.release(incomingToken);
}
if (this.buffer.length > 0) {
this.retainedToken = this.ledger.reserve("decoder_buffer", this.buffer.length);
if (this.retainedToken === null) {
this.buffer = EMPTY;
this.discarding = true;
}
}
}
}
@@ -1,17 +1,17 @@
{
"frameVersion": 1,
"frameVersion": 2,
"defaultMaxFrameBytes": 1000000,
"description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; splitByteOffsets cuts it into chunks; expected lists the ordered decode results (frame or protocol-error code).",
"vectors": [
{
"name": "valid-request",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "request",
"id": "req-1",
"method": "GET",
@@ -21,7 +21,30 @@
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
"bodyByteCount": 0
}
}
]
},
{
"name": "valid-request-with-body-count",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-body\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":1048576}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "request",
"id": "req-body",
"method": "GET",
"path": "/api/issues/PAP-1",
"query": "expand=comments",
"headers": {
"accept": "application/json",
"content-type": "application/json"
},
"bodyByteCount": 1048576
}
}
]
@@ -29,19 +52,19 @@
{
"name": "valid-response-completed",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "response",
"id": "req-1",
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"ok\":true}",
"bodyByteCount": 11,
"outcome": "completed"
}
}
@@ -50,19 +73,19 @@
{
"name": "valid-response-indeterminate",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"outcome_indeterminate\\\"}\",\"outcome\":\"indeterminate\"}\n",
"bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":33,\"outcome\":\"indeterminate\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "response",
"id": "req-2",
"status": 409,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":\"outcome_indeterminate\"}",
"bodyByteCount": 33,
"outcome": "indeterminate"
}
}
@@ -71,43 +94,121 @@
{
"name": "valid-response-unavailable",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"bridge_unavailable\\\"}\",\"outcome\":\"unavailable\"}\n",
"bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":30,\"outcome\":\"unavailable\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "response",
"id": "req-3",
"status": 503,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":\"bridge_unavailable\"}",
"bodyByteCount": 30,
"outcome": "unavailable"
}
}
]
},
{
"name": "valid-body-chunk",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":\"aGVsbG8gYm9keSBjaHVuaw==\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "body_chunk",
"id": "req-1",
"seq": 0,
"data": "aGVsbG8gYm9keSBjaHVuaw=="
}
}
]
},
{
"name": "valid-body-chunk-final",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":7,\"data\":\"dGFpbA==\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "body_chunk",
"id": "req-1",
"seq": 7,
"data": "dGFpbA=="
}
}
]
},
{
"name": "valid-ready",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\"}\n",
"bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "ready",
"nonce": "9f8e7d6c5b4a39281706f5e4d3c2b1a0"
}
}
]
},
{
"name": "valid-heartbeat",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"heartbeat\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "heartbeat"
}
}
]
},
{
"name": "valid-close",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"close\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "close"
}
}
]
},
{
"name": "valid-error",
"category": "valid",
"bytes": "{\"version\":2,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 2,
"type": "error",
"code": "malformed_frame",
"message": "peer reported a malformed frame"
}
}
]
},
{
"name": "invalid-ready-missing-nonce",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"ready\"}\n",
"bytes": "{\"version\":2,\"type\":\"ready\"}\n",
"expected": [
{
"error": "malformed_frame"
@@ -117,7 +218,7 @@
{
"name": "invalid-ready-with-address",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"address\":\"http://127.0.0.1:47215\"}\n",
"bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"address\":\"http://127.0.0.1:47215\"}\n",
"expected": [
{
"error": "malformed_frame"
@@ -127,57 +228,13 @@
{
"name": "invalid-ready-with-port",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"port\":47215}\n",
"bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"port\":47215}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "valid-heartbeat",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"heartbeat\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "heartbeat"
}
}
]
},
{
"name": "valid-close",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"close\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "close"
}
}
]
},
{
"name": "valid-error",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "error",
"code": "malformed_frame",
"message": "peer reported a malformed frame"
}
}
]
},
{
"name": "invalid-not-json",
"category": "invalid",
@@ -211,7 +268,7 @@
{
"name": "invalid-unknown-type",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"teleport\"}\n",
"bytes": "{\"version\":2,\"type\":\"teleport\"}\n",
"expected": [
{
"error": "unknown_type"
@@ -221,7 +278,37 @@
{
"name": "invalid-missing-id",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-request-body-count-string",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":\"10\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-request-body-count-negative",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":-1}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-request-body-count-fractional",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":1.5}\n",
"expected": [
{
"error": "malformed_frame"
@@ -231,7 +318,47 @@
{
"name": "invalid-bad-headers",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"bodyByteCount\":10,\"outcome\":\"completed\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-body-chunk-negative-seq",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":-1,\"data\":\"eA==\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-body-chunk-fractional-seq",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":2.5,\"data\":\"eA==\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-body-chunk-missing-data",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-body-chunk-nonstring-data",
"category": "invalid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":42}\n",
"expected": [
{
"error": "malformed_frame"
@@ -241,14 +368,14 @@
{
"name": "partial-request-split",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n",
"splitByteOffsets": [
98
102
],
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "request",
"id": "req-1",
"method": "GET",
@@ -258,7 +385,7 @@
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
"bodyByteCount": 0
}
}
]
@@ -266,23 +393,24 @@
{
"name": "partial-utf8-split",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1/comments\",\"query\":\"\",\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"text\\\":\\\"héllo 😀 wörld\\\"}\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"content-type\":\"application/json\",\"x-note\":\"héllo 😀 wörld\"},\"bodyByteCount\":0}\n",
"splitByteOffsets": [
177
172
],
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "request",
"id": "req-utf8",
"method": "POST",
"path": "/api/issues/PAP-1/comments",
"query": "",
"path": "/api/issues/PAP-1",
"query": "expand=comments",
"headers": {
"content-type": "application/json"
"content-type": "application/json",
"x-note": "héllo 😀 wörld"
},
"body": "{\"text\":\"héllo 😀 wörld\"}"
"bodyByteCount": 0
}
}
]
@@ -290,16 +418,16 @@
{
"name": "partial-two-frames",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n",
"splitByteOffsets": [
7,
199,
343
174,
344
],
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "request",
"id": "req-1",
"method": "GET",
@@ -309,19 +437,19 @@
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
"bodyByteCount": 0
}
},
{
"frame": {
"version": 1,
"version": 2,
"type": "response",
"id": "req-1",
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"ok\":true}",
"bodyByteCount": 11,
"outcome": "completed"
}
}
@@ -330,14 +458,14 @@
{
"name": "partial-no-trailing-newline",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"heartbeat\"}",
"bytes": "{\"version\":2,\"type\":\"heartbeat\"}",
"expected": []
},
{
"name": "oversized-complete-line",
"category": "oversized",
"maxFrameBytes": 64,
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"q=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n",
"expected": [
{
"error": "frame_too_large"
@@ -351,23 +479,33 @@
"splitByteOffsets": [
40
],
"bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":1,\"type\":\"heartbeat\"}\n",
"bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":2,\"type\":\"heartbeat\"}\n",
"expected": [
{
"error": "frame_too_large"
},
{
"frame": {
"version": 1,
"version": 2,
"type": "heartbeat"
}
}
]
},
{
"name": "version-mismatch-lower",
"category": "versionMismatch",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n",
"expected": [
{
"error": "version_mismatch"
}
]
},
{
"name": "version-mismatch-higher",
"category": "versionMismatch",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"bytes": "{\"version\":3,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n",
"expected": [
{
"error": "version_mismatch"
@@ -387,19 +525,19 @@
{
"name": "request-id-at-max-bytes",
"category": "idBoundValid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"version": 2,
"type": "request",
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"method": "GET",
"path": "/",
"query": "",
"headers": {},
"body": ""
"bodyByteCount": 0
}
}
]
@@ -407,12 +545,87 @@
{
"name": "request-id-over-max-bytes",
"category": "idBoundInvalid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n",
"expected": [
{
"error": "id_too_large"
}
]
},
{
"name": "body-chunk-id-over-max-bytes",
"category": "idBoundInvalid",
"bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"seq\":0,\"data\":\"eA==\"}\n",
"expected": [
{
"error": "id_too_large"
}
]
}
],
"encodeDescription": "Encode-bound vectors. Each codec copy runs the size-checked encode against the frame with the maxFrameBytes limit, then asserts the same result. An ok result must decode back to the same frame. This proves both copies enforce the same bound on encode.",
"encodeVectors": [
{
"name": "encode-within-bound",
"maxFrameBytes": 200,
"frame": {
"version": 2,
"type": "request",
"id": "req-enc-1",
"method": "GET",
"path": "/",
"query": "",
"headers": {},
"bodyByteCount": 5
},
"expected": {
"ok": true
}
},
{
"name": "encode-body-chunk-within-bound",
"maxFrameBytes": 200,
"frame": {
"version": 2,
"type": "body_chunk",
"id": "req-enc-3",
"seq": 0,
"data": "Y2h1bmsgcGF5bG9hZA=="
},
"expected": {
"ok": true
}
},
{
"name": "encode-control-frame-never-rejected",
"maxFrameBytes": 200,
"frame": {
"version": 2,
"type": "heartbeat"
},
"expected": {
"ok": true
}
},
{
"name": "encode-over-bound",
"maxFrameBytes": 200,
"frame": {
"version": 2,
"type": "response",
"id": "req-enc-2",
"status": 200,
"headers": {
"content-type": "application/json",
"x-pad": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"bodyByteCount": 0,
"outcome": "completed"
},
"expected": {
"ok": false,
"error": "frame_too_large"
}
}
]
}
+44 -4
View File
@@ -35,6 +35,40 @@ export const DUPLEX_COUNTER_LOSS_TOTAL = "sandbox_duplex_loss_total";
/** The guarded counter for one leaked provider session on teardown. */
export const DUPLEX_COUNTER_SESSION_LEAK_TOTAL = "sandbox_duplex_session_leak_total";
/**
* The process-scoped gauge for the aggregate retained bytes across every live
* duplex route. The host aggregate byte ledger sets it on each reserve and each
* release. The record carries no dynamic dimension.
*/
export const DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE = "sandbox_duplex_aggregate_bytes_in_use";
/**
* The counter for one rejected aggregate byte reservation. The host ledger
* increments it when a reservation would pass the aggregate ceiling. The record
* carries no dynamic dimension.
*/
export const DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL =
"sandbox_duplex_aggregate_byte_reservation_rejections_total";
/**
* The counter for one aggregate byte accounting defect. The host ledger
* increments it on a double release or a transfer of a token it does not hold.
* The record carries no dynamic dimension.
*/
export const DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL =
"sandbox_duplex_aggregate_byte_accounting_underflow_total";
/**
* The closed set of aggregate byte ledger metric names. A test pins this exact
* set, so a new ledger metric name needs an explicit review. Each record uses
* only closed constant dimensions and no dynamic label. The telemetry contract
* documents these metrics under "Aggregate byte ledger metrics" in
* `packages/shared/src/telemetry/README.md`.
*/
export const DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES = [
DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE,
DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL,
DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL,
] as const;
/**
* The closed dimension-key set. Every span attribute, counter label, and event
* field uses only these keys. A test asserts the exact set, so a new key never
@@ -64,6 +98,9 @@ export type DuplexOutcomeValue = "ok" | "error";
* stage: the process-scoped route ceiling was full (`route_busy`), the entrypoint
* sync failed (`entrypoint_sync_failed`), the broker construction failed
* (`broker_construction_failed`), or the channel open failed (`channel_open_failed`).
* The `aggregate_bytes_exceeded` reason names a readiness handshake the host fell
* back because the process aggregate byte ceiling had no room for the readiness
* buffer.
*/
export type DuplexFallbackReason =
| "gate_off"
@@ -75,7 +112,8 @@ export type DuplexFallbackReason =
| "ready_invalid"
| "ready_nonce_mismatch"
| "ready_timeout"
| "contaminated";
| "contaminated"
| "aggregate_bytes_exceeded";
/** The class of a terminal loss, relative to the first request dispatch. */
export type DuplexLossClass = "pre_dispatch" | "post_dispatch";
@@ -85,9 +123,10 @@ export type DuplexLossClass = "pre_dispatch" | "post_dispatch";
* cause to one of these values before any sink reads it. The set covers the
* loss-detection modes the transport names: a gateway stdin end of file, a
* provider process exit, a heartbeat timeout, and an RPC failure. It adds
* `write_error` for a rejected host-to-sandbox write and `other` for an unknown
* cause. The host maps an unknown cause or any caught provider text to `other`,
* so no raw provider text reaches a sink.
* `write_error` for a rejected host-to-sandbox write, `transport_closed` for a
* reason-less provider transport close with no exit data, and `other` for an
* unknown cause. The host maps an unknown cause or any caught provider text to
* `other`, so no raw provider text reaches a sink.
*/
export const DUPLEX_LOSS_REASONS = [
"stdin_eof",
@@ -95,6 +134,7 @@ export const DUPLEX_LOSS_REASONS = [
"heartbeat_timeout",
"rpc_failure",
"write_error",
"transport_closed",
"other",
] as const;
File diff suppressed because it is too large. Load diff
+344 -31
View File
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { randomBytes, randomUUID } from "node:crypto";
import type { SshRemoteExecutionSpec } from "./ssh.js";
import {
@@ -29,6 +30,7 @@ import {
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES,
SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE,
SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT,
sandboxCallbackBridgeDirectories,
@@ -58,11 +60,17 @@ import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
type DuplexRequestFrame,
} from "./duplex-frame-codec.js";
import type { ReassembledBody } from "./duplex-body-spool.js";
import {
createDuplexTelemetry,
type DuplexFallbackReason,
type DuplexTelemetryRecorder,
} from "./duplex-telemetry.js";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type ReservationToken,
} from "./duplex-aggregate-byte-ledger.js";
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import {
ensureCommandResolvable,
@@ -184,6 +192,16 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
* observability surface. Absent means the safe no-op default.
*/
duplexTelemetryRecorder?: DuplexTelemetryRecorder | null;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel. The
* host stamps this same object on every sandbox target on the same seam as
* `runner`, so one shared gauge bounds the aggregate retained bytes across all
* live duplex routes. The live object stays on the host and never enters the
* sandbox environment. The bridge passes it to the broker, the decoder, and the
* response-body reader. Absent means no host ledger; a non-duplex run keeps the
* bridge inert for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
export type AdapterExecutionTarget =
@@ -428,6 +446,20 @@ export function adapterExecutionTargetDuplexTelemetryRecorder(
: null;
}
/**
* Read the injected aggregate byte ledger off a target. Only a sandbox target
* with a ledger attached returns it. Every other target returns null, so the
* bridge stays inert for this seam. The reader never makes a fresh ledger, so a
* host duplex run always uses the one process-owned ledger the host stamped.
*/
export function adapterExecutionTargetDuplexAggregateByteLedger(
target: AdapterExecutionTarget | null | undefined,
): DuplexAggregateByteLedger | null {
return target?.kind === "remote" && target.transport === "sandbox"
? target.duplexAggregateByteLedger ?? null
: null;
}
export function adapterExecutionTargetRemoteCwd(
target: AdapterExecutionTarget | null | undefined,
localCwd: string,
@@ -1468,7 +1500,27 @@ function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
/**
* Read the forward response body into a string. The reader bounds the body with
* two controls. The per-request `maxBodyBytes` limit rejects a body larger than
* the configured per-request ceiling. The optional host aggregate byte ledger
* bounds the retained bytes across all live routes.
*
* The reader charges the ledger for every retained buffer before it allocates
* that buffer. It reserves the exact chunk bytes before it copies a chunk into a
* retained `Buffer`. It reserves the concatenation buffer before it allocates it.
* A reservation that would pass the aggregate ceiling returns no token; the
* reader retains nothing more, cancels the stream reader, and throws the fixed
* marker {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}. The `finally` releases
* every token exactly one time, so the reader charges the retained bytes only
* while the raw buffers live and never leaves a token held after it returns or
* throws.
*/
async function readBridgeForwardResponseBody(
response: Response,
maxBodyBytes: number,
ledger?: DuplexAggregateByteLedger | null,
): Promise<string> {
const rawContentLength = response.headers.get("content-length");
if (rawContentLength) {
const contentLength = Number.parseInt(rawContentLength, 10);
@@ -1483,19 +1535,54 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
const reader = response.body.getReader();
const chunks: Buffer[] = [];
// Every response-body reservation token the reader holds. The `finally` block
// releases each token one time, so a return, a size error, an aggregate
// rejection, and a read error all release every token.
const tokens: ReservationToken[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkBytes = value.byteLength;
totalBytes += chunkBytes;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
// Reserve the exact chunk bytes before the host copies the chunk into a
// retained buffer. A rejection fails closed: cancel the stream reader and
// report the fixed marker; the reader retains nothing more.
if (ledger) {
const token = ledger.reserve("response_body", chunkBytes);
if (!token) {
await reader.cancel().catch(() => undefined);
throw new Error(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
}
tokens.push(token);
}
chunks.push(Buffer.from(value));
}
// Reserve the concatenation buffer before the reader allocates it. The
// concatenation buffer is a second copy of the body bytes that lives next to
// the chunk buffers during the concatenation, so it is the peak retained
// allocation. A rejection fails closed with the fixed marker.
if (ledger && totalBytes > 0) {
const concatToken = ledger.reserve("response_body", totalBytes);
if (!concatToken) {
throw new Error(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
}
tokens.push(concatToken);
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
} finally {
if (ledger) {
for (const token of tokens) {
ledger.release(token);
}
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
@@ -2394,7 +2481,8 @@ type DuplexReadinessFailure =
| "protocol_contamination"
| "nonce_mismatch"
| "channel_exit"
| "timeout";
| "timeout"
| "aggregate_bytes_exceeded";
/** The outcome of the duplex readiness handshake. */
type DuplexReadinessResult =
@@ -2416,6 +2504,8 @@ function duplexReadinessFallbackReason(reason: DuplexReadinessFailure): DuplexFa
return "ready_timeout";
case "channel_exit":
return "ready_invalid";
case "aggregate_bytes_exceeded":
return "aggregate_bytes_exceeded";
}
}
@@ -2542,6 +2632,13 @@ export const __duplexReadinessTesting = {
resetNewlineScanUnits: (): void => {
duplexReadinessNewlineScanUnits = 0;
},
// Build one readiness gate over a supplied channel, so a test can drive the
// readiness-replay reservation lifecycle across every terminal path without the
// whole bridge. Production code never reads this factory.
createReadinessGate: (
channel: CommandManagedDuplexChannel,
options: { nonce: string; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null },
) => createDuplexReadinessGate(channel, options),
};
interface DuplexReadinessGate {
@@ -2554,14 +2651,82 @@ interface DuplexReadinessGate {
* the channel.
*/
readonly brokerChannel: CommandManagedDuplexChannel;
/**
* Report whether a post-READY pre-bind chunk could not reserve its replay bytes
* against the aggregate ledger. On such a refusal the gate drops the pending
* replay buffer and stops the channel. The caller reads this after `ready`
* resolves `ok`, and before it binds the broker. A `true` result means the caller
* must abandon the broker and select the file bridge with the aggregate marker.
*/
replayOverflowed(): boolean;
/**
* Release every held readiness-replay reservation exactly once and drop the
* pending replay buffer. The caller runs this on a terminal path that abandons
* the pending replay without a broker handoff: a readiness failure, a replay
* overflow, or a broker-construction failure. The normal handoff releases the
* reservation inside `brokerChannel.onData`, so a later call here is a no-op.
*/
disposePendingReplay(): void;
/**
* Test-only. Report the length of the retained pre-READY buffer, in UTF-16 code
* units. A test reads this to prove the gate drops the pre-READY buffer on READY
* acceptance, so the process does not retain the sandbox-controlled prefix.
* Production code does not read this.
*/
retainedReadinessBufferLength(): number;
}
function createDuplexReadinessGate(
channel: CommandManagedDuplexChannel,
options: { nonce: string; timeoutMs: number },
options: {
nonce: string;
timeoutMs: number;
// The one host-process aggregate byte ledger. The gate charges the untrusted
// pre-READY buffer bytes against it, so a pre-READY flood counts toward the
// aggregate ceiling across all live routes. A gate with no ledger stays inert
// for this seam. The gate holds the same object every other host retention
// site holds, so the aggregate identity holds at this seam.
ledger?: DuplexAggregateByteLedger | null;
},
): DuplexReadinessGate {
const ledger = options.ledger ?? null;
let settled = false;
let readyOk = false;
// Every readiness-buffer reservation token the gate holds for the pre-READY
// bytes. The gate releases each token one time when it settles or when it
// accepts READY. On a failed handshake the gate drops the buffer, so the release
// frees the untrusted bytes. On READY the gate discards the whole pre-READY
// buffer, then re-charges only the retained suffix under `readiness_replay`.
const retainedTokens: ReservationToken[] = [];
// Every readiness-replay reservation token the gate holds for the post-READY
// suffix and each later pre-bind chunk. The gate releases each token one time
// after the synchronous handoff to the broker, or on a terminal path that
// abandons the pending replay without a broker handoff.
const replayTokens: ReservationToken[] = [];
// The gate sets this when a post-READY pre-bind chunk cannot reserve its replay
// bytes. On that refusal the gate drops the pending buffer and stops the channel.
// The caller reads it through `replayOverflowed` and selects the file bridge.
let replayOverflow = false;
// Release every readiness-buffer token exactly once and clear the registry. A
// second call is a no-op, because the array is empty.
function releaseReadinessBufferTokens(): void {
if (!ledger) return;
for (const token of retainedTokens) {
ledger.release(token);
}
retainedTokens.length = 0;
}
// Release every readiness-replay token exactly once and clear the registry. A
// second call is a no-op, because the array is empty.
function releaseReplayTokens(): void {
if (!ledger) return;
for (const token of replayTokens) {
ledger.release(token);
}
replayTokens.length = 0;
}
// The raw bytes the host reads before the READY frame completes. The buffer is
// append-only, so the O(1) cap check on `buffer.length` stays valid.
let buffer = "";
@@ -2590,6 +2755,11 @@ function createDuplexReadinessGate(
settled = true;
clearTimeout(timer);
if (result.ok) readyOk = true;
// Release every readiness-buffer token exactly once. The gate no longer owns
// the pre-READY bytes: a failed handshake drops the buffer. The READY-accept
// path already released these tokens and charged the retained suffix under
// `readiness_replay`, so this call is a no-op there.
releaseReadinessBufferTokens();
resolveReady(result);
}
@@ -2599,7 +2769,24 @@ function createDuplexReadinessGate(
return;
}
if (readyOk) {
// READY already passed; hold the bytes until the broker binds.
// READY already passed; hold the bytes until the broker binds. Reserve the
// exact UTF-8 bytes under `readiness_replay` before the append, so the replay
// buffer counts toward the aggregate ceiling. A refusal fails closed: the gate
// drops the pending buffer, releases the replay tokens, stops the channel, and
// sets the overflow flag. The caller reads the flag and selects the file bridge
// with the aggregate marker, because `ready` already resolved before this
// synchronous post-READY chunk arrived.
if (ledger) {
const token = ledger.reserve("readiness_replay", Buffer.byteLength(chunk, "utf8"));
if (!token) {
replayOverflow = true;
pending = "";
releaseReplayTokens();
channel.stop();
return;
}
replayTokens.push(token);
}
pending += chunk;
return;
}
@@ -2609,6 +2796,18 @@ function createDuplexReadinessGate(
// never grows the buffer after the gate settles.
return;
}
// Reserve the exact UTF-8 bytes of this chunk against the aggregate ledger
// before the gate retains it. The pre-READY buffer holds untrusted bytes, so
// a flood counts toward the process aggregate ceiling. A rejection fails
// closed: the gate retains nothing more and falls back to the file bridge.
if (ledger) {
const token = ledger.reserve("readiness_buffer", Buffer.byteLength(chunk, "utf8"));
if (!token) {
finish({ ok: false, reason: "aggregate_bytes_exceeded" });
return;
}
retainedTokens.push(token);
}
// Append the new bytes and continue the newline search from `scanFrom`, the
// first index not yet examined. Each code unit is read at most one time for
// the search, so the total scan work stays linear in the bytes received.
@@ -2682,8 +2881,33 @@ function createDuplexReadinessGate(
finish({ ok: false, reason: "nonce_mismatch" });
return;
}
// Hold the bytes that follow the READY line for the broker to replay.
pending = buffer.slice(newlineIndex + 1);
// The bytes that follow the READY line become the replay buffer for the
// broker. Drop the whole pre-READY buffer charge first, then reserve the
// retained suffix under `readiness_replay`. The release-before-reserve order
// keeps the transient charge equal to the suffix, not the sum of the dropped
// prefix and the retained suffix. The two steps run in one synchronous
// section, so no other route can take the freed bytes in between.
const suffix = buffer.slice(newlineIndex + 1);
// Drop the original pre-READY buffer now. The gate keeps only the
// retained suffix as `pending`, and it charges that suffix under
// `readiness_replay` below. If the gate keeps the buffer, the process
// retains the full sandbox-controlled string while the ledger counts
// only the suffix, so aggregate retention passes the ceiling. This clear
// also covers the broker handoff and the replay disposal. Both run later
// and read no buffer bytes.
buffer = "";
releaseReadinessBufferTokens();
if (ledger && suffix.length > 0) {
const token = ledger.reserve("readiness_replay", Buffer.byteLength(suffix, "utf8"));
if (!token) {
// The retained suffix passes the aggregate ceiling. Fail closed: drop
// the suffix and fall back to the file bridge with the aggregate marker.
finish({ ok: false, reason: "aggregate_bytes_exceeded" });
return;
}
replayTokens.push(token);
}
pending = suffix;
finish({ ok: true });
return;
}
@@ -2723,6 +2947,11 @@ function createDuplexReadinessGate(
pending = "";
listener(replay);
}
// Release every readiness-replay token exactly once, after the synchronous
// handoff to the broker. The broker charges its own decode retention inside
// the `listener(replay)` call above, so the release here never opens an
// admission gap for the same retained bytes.
releaseReplayTokens();
},
onExit: (listener: (exit: { exitCode: number | null }) => void) => {
exitSink = listener;
@@ -2736,7 +2965,16 @@ function createDuplexReadinessGate(
close: () => channel.close(),
};
return { ready, brokerChannel };
return {
ready,
brokerChannel,
replayOverflowed: () => replayOverflow,
disposePendingReplay: () => {
pending = "";
releaseReplayTokens();
},
retainedReadinessBufferLength: () => buffer.length,
};
}
/**
@@ -2817,6 +3055,12 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
const target = input.target;
// The process-owned aggregate byte ledger the host stamped on this sandbox
// target. The forward response-body reader charges its retained bytes against
// this one ledger, so the aggregate retained bytes across all live routes stay
// under the ceiling. A target with no ledger keeps the reader inert for this
// seam.
const duplexAggregateByteLedger = adapterExecutionTargetDuplexAggregateByteLedger(target);
const onLog = input.onLog ?? (async () => {});
const hostApiToken = input.hostApiToken?.trim() ?? "";
if (hostApiToken.length === 0) {
@@ -2890,9 +3134,24 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// file bridge and the duplex broker. The sandbox request carries only the
// bridge token; the real agent token never leaves the host.
const forwardBridgeRequest = async (
request: { method: string; path: string; query: string; headers: Record<string, string>; body: string },
request: {
method: string;
path: string;
query: string;
headers: Record<string, string>;
/** The file bridge passes the whole request body here as one string. */
body?: string;
},
signal?: AbortSignal,
options?: { suppressDebugLog?: boolean },
options?: {
suppressDebugLog?: boolean;
/**
* The duplex broker passes the reassembled request body here. The forward
* streams it to the host, so a spilled body never loads into memory on the
* receive side. The forward never disposes it; the broker owns its lifecycle.
*/
reassembledBody?: ReassembledBody;
},
): Promise<{ status: number; headers: Record<string, string>; body: string }> => {
const method = request.method.trim().toUpperCase() || "GET";
// The per-request debug log prints the method, the path, and the query. The
@@ -2917,12 +3176,26 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// the forward budget here, whichever comes first.
const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs);
const forwardSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
// Build the request-body init. A GET or a HEAD carries no body. The duplex
// broker passes the reassembled body: stream it, so a body that spilled to
// disk never loads into memory here. A streamed body needs `duplex: "half"`.
// The file bridge passes the whole body as one string.
const forwardInit: RequestInit & { duplex?: "half" } = {
method,
headers,
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
signal: forwardSignal,
});
};
if (method !== "GET" && method !== "HEAD") {
if (options?.reassembledBody) {
forwardInit.body = Readable.toWeb(
options.reassembledBody.createReadStream(),
) as unknown as ReadableStream<Uint8Array>;
forwardInit.duplex = "half";
} else if (typeof request.body === "string") {
forwardInit.body = request.body;
}
}
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit);
if (emitDebugLog) {
await onLog(
"stdout",
@@ -2943,7 +3216,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// to a non-retryable 409 for both the file bridge and the duplex broker.
let responseBody: string;
try {
responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
responseBody = await readBridgeForwardResponseBody(
response,
maxBodyBytes,
duplexAggregateByteLedger,
);
} catch (error) {
if (isSafeBridgeMethod(method)) {
// The method is safe, so a retry cannot double-apply a mutation. Return a
@@ -3049,6 +3326,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
PAPERCLIP_BRIDGE_PORT: String(assignedPort),
PAPERCLIP_BRIDGE_NONCE: nonce,
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes),
// The separate sandbox-process raw-decoder cap. The generated gateway runs
// in a different operating-system process, so it cannot share the host
// aggregate byte ledger. It enforces this cap locally under the
// `sandbox_process` scope, and the provider memory allocation bounds it.
PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES: String(DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES),
};
const command = buildDuplexGatewayLaunchArgv({
shellCommand,
@@ -3080,7 +3362,14 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
if (channel) {
const gate = createDuplexReadinessGate(channel, { nonce, timeoutMs: readinessTimeoutMs });
const gate = createDuplexReadinessGate(channel, {
nonce,
timeoutMs: readinessTimeoutMs,
// Inject the one host-process aggregate byte ledger, the same object the
// broker and the response-body reader hold. The gate charges the untrusted
// pre-READY buffer against it, so the aggregate identity holds at this seam.
ledger: duplexAggregateByteLedger,
});
const readiness = await gate.ready;
if (!readiness.ok) {
// Fail closed. Close the partial channel inside a bounded budget, then
@@ -3088,12 +3377,26 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// carries the bridge token reached the channel or any endpoint. The reason
// is a fixed enum, so it rides the log line and the fallback telemetry
// with no raw value.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback(duplexReadinessFallbackReason(readiness.reason));
await onLog(
"stderr",
`[paperclip] Sandbox duplex readiness failed (${readiness.reason}). Using the file bridge.\n`,
);
} else if (gate.replayOverflowed()) {
// Readiness passed, but a post-READY pre-bind chunk passed the aggregate
// byte ceiling. The gate dropped the replay buffer and stopped the channel.
// Release any held replay reservation, close the partial channel inside the
// cleanup budget, and select the file bridge with the aggregate marker. The
// broker never bound, so no request reached the channel or any endpoint.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback(duplexReadinessFallbackReason("aggregate_bytes_exceeded"));
await onLog(
"stderr",
"[paperclip] Sandbox duplex readiness replay exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n",
);
} else {
// Readiness passed. Construct the broker inside the guarded region, so a
// construction throw closes the channel within the cleanup budget and
@@ -3103,14 +3406,14 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// token only now, after readiness passed.
let broker: DuplexBridgeBroker | null = null;
try {
broker = createDuplexBridgeBroker({
broker = await createDuplexBridgeBroker({
channel: gate.brokerChannel,
// Derive the nested budgets from the forward budget, so any forward
// budget keeps the nested order the broker asserts at construction.
budgets: deriveNestedDuplexBrokerBudgets(forwardTimeoutMs),
forwardRequest: async (
request: DuplexRequestFrame,
options: { signal: AbortSignal },
options: { signal: AbortSignal; body: ReassembledBody },
): Promise<DuplexBrokerForwardResult> => {
const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes(request);
if (denialReason) {
@@ -3130,9 +3433,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
headers: sanitizeSandboxCallbackBridgeHeaders(request.headers),
};
// Suppress the per-request debug log on the duplex path, so no route
// or query rides a log line here.
// or query rides a log line here. Stream the reassembled request
// body, so a spilled body never loads into memory on the host.
return forwardBridgeRequest(sanitizedRequest, options.signal, {
suppressDebugLog: true,
reassembledBody: options.body,
});
},
// The duplex path emits only the fixed transport telemetry. It passes no
@@ -3149,12 +3454,20 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
`[paperclip] Sandbox duplex channel lost (${typedDuplexLossReason(record.reason)}). The run fails.\n`,
);
},
// Inject the one host-process aggregate byte ledger. The broker
// reserves the retained request-frame, request-payload, and no-replay
// set-entry bytes against it, so the aggregate retained bytes across all
// live routes stay under the ceiling. It is the same object the
// response-body reader reads off the stamped target above.
duplexAggregateByteLedger,
});
} catch {
// The broker construction failed, so no broker owns the channel. Close
// the channel within the cleanup budget, then select the file bridge.
// The log line names no raw error, so no raw error rides a log line on
// the duplex path.
// The broker construction failed, so no broker owns the channel. The
// broker never bound, so it never released the pending replay reservation;
// release it here. Then close the channel within the cleanup budget and
// select the file bridge. The log line names no raw error, so no raw error
// rides a log line on the duplex path.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback("broker_construction_failed");
await onLog(
-2
View File
@@ -86,7 +86,6 @@ export type {
export { inferOpenAiCompatibleBiller } from "./billing.js";
export {
ADAPTER_LOGIN_PANEL_MODES,
ADAPTER_LOGIN_SANDBOX_TRANSPORTS,
ADAPTER_LOGIN_TIMEOUT_POLICIES,
ADAPTER_LOGIN_COMPLETION_CLAIMS,
assertValidAdapterLoginCapability,
@@ -94,7 +93,6 @@ export {
} from "./login-capability.js";
export type {
AdapterLoginPanelMode,
AdapterLoginSandboxTransport,
AdapterLoginTimeoutPolicy,
AdapterLoginCompletionClaim,
AdapterLoginPrompt,
@@ -10,7 +10,6 @@ import {
function validCapability(): AdapterLoginCapability {
return {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
@@ -25,7 +24,6 @@ describe("assertValidAdapterLoginCapability", () => {
it("accepts a well-formed capability with every optional member set", () => {
const capability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => "vendor setup-token",
parsePrompt: () => null,
@@ -48,11 +46,6 @@ describe("assertValidAdapterLoginCapability", () => {
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/);
});
it("rejects an unknown sandboxTransport", () => {
const bad = { ...validCapability(), sandboxTransport: "http" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/);
});
it("rejects an unknown timeoutPolicy", () => {
const bad = { ...validCapability(), timeoutPolicy: "unbounded" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/);
@@ -18,14 +18,6 @@
export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const;
export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number];
/**
* The sandbox transport that the login command needs. `streamed_exec` runs the
* command over the streamed exec channel. `pseudo_terminal` runs the command on
* a real pseudo-terminal, because a pipe emits no login prompt.
*/
export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const;
export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number];
/**
* The host-side timeout policy. `caller_bounded` lets the caller set the
* timeout. `fixed` binds the timeout to a fixed adapter value.
@@ -68,8 +60,6 @@ export interface AdapterLoginCompletionContext {
export interface AdapterLoginCapability {
/** The login panel mode. */
panelMode: AdapterLoginPanelMode;
/** The sandbox transport that the login command needs. */
sandboxTransport: AdapterLoginSandboxTransport;
/** The host-side timeout policy. */
timeoutPolicy: AdapterLoginTimeoutPolicy;
/** Returns the fixed, non-secret login command. */
@@ -122,11 +112,6 @@ export function assertValidAdapterLoginCapability(
`${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) {
throw new Error(
`${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) {
throw new Error(
`${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`,
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import {
createSetupTokenPtyTransport,
type SetupTokenPtySession,
} from "./setup-token-transport.js";
createLoginPtyTransport,
type LoginPtySession,
} from "./login-pty-transport.js";
// The Enter byte the terminal login UI reads to submit the browser code. The
// login runner appends this byte to the code. The transport forwards the bytes
@@ -14,7 +14,7 @@ const ENTER = "\r";
* stream on demand, and records the stop and the close. The tests use it in
* place of a real pseudo-terminal, so the transport runs with no sandbox.
*/
function createFakePtySession(): SetupTokenPtySession & {
function createFakePtySession(): LoginPtySession & {
writes: string[];
emit: (chunk: string) => void;
finish: (exitCode: number | null) => void;
@@ -74,10 +74,10 @@ function createFakePtySession(): SetupTokenPtySession & {
};
}
describe("createSetupTokenPtyTransport", () => {
describe("createLoginPtyTransport", () => {
it("delivers delayed input to the process", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", () => {});
await session.ready;
@@ -94,7 +94,7 @@ describe("createSetupTokenPtyTransport", () => {
it("returns incremental terminal output to the runner", async () => {
const session = createFakePtySession();
const received: string[] = [];
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", (chunk) => {
received.push(chunk);
@@ -114,7 +114,7 @@ describe("createSetupTokenPtyTransport", () => {
it("delivers the Enter byte after the browser code", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", () => {});
await session.ready;
@@ -133,7 +133,7 @@ describe("createSetupTokenPtyTransport", () => {
it("resolves start with the child exit code", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", () => {});
session.finish(7);
@@ -143,7 +143,7 @@ describe("createSetupTokenPtyTransport", () => {
it("stops the child with a direct kill", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", () => {});
await session.ready;
@@ -156,7 +156,7 @@ describe("createSetupTokenPtyTransport", () => {
it("disposes the session resources", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
const started = transport.start("claude setup-token", () => {});
session.finish(0);
@@ -168,7 +168,7 @@ describe("createSetupTokenPtyTransport", () => {
it("stays safe when stop and dispose run before start", async () => {
const session = createFakePtySession();
const transport = createSetupTokenPtyTransport(async () => session);
const transport = createLoginPtyTransport(async () => session);
// The runner may stop or dispose before it starts the child. The transport
// must not throw, and it must not open a session for a run it already stopped.
@@ -185,7 +185,7 @@ describe("createSetupTokenPtyTransport", () => {
const openGate = new Promise<void>((resolve) => {
resolveOpen = resolve;
});
const transport = createSetupTokenPtyTransport(async () => {
const transport = createLoginPtyTransport(async () => {
await openGate;
return session;
});
@@ -207,7 +207,7 @@ describe("createSetupTokenPtyTransport", () => {
const openGate = new Promise<void>((resolve) => {
resolveOpen = resolve;
});
const transport = createSetupTokenPtyTransport(async () => {
const transport = createLoginPtyTransport(async () => {
await openGate;
return session;
});
@@ -1,4 +1,4 @@
// The setup-token pseudo-terminal transport. It gives the Claude `setup-token`
// The login pseudo-terminal (PTY) transport. It gives the Claude `setup-token`
// login runner a child that runs on a real pseudo-terminal (PTY). The command
// needs a PTY: pipe stdio emits no login prompt. The transport starts the
// command on a PTY, streams the incremental terminal output, delivers delayed
@@ -7,7 +7,7 @@
// This module is provider-agnostic and pure. It holds no Node built-in import,
// so the browser-safe root entry can re-export it. A sandbox provider (for
// example the Daytona plugin) opens the concrete pseudo-terminal through a
// {@link SetupTokenPtySessionOpener}. A unit test opens a fake pseudo-terminal
// {@link LoginPtySessionOpener}. A unit test opens a fake pseudo-terminal
// through the same opener.
//
// Boundary (ANSI and OSC 8): the transport forwards the raw terminal bytes
@@ -20,7 +20,7 @@
* raw terminal output, accepts delayed input, and stops the child. The session
* forwards the raw terminal bytes; it runs no ANSI or OSC 8 handling.
*/
export interface SetupTokenPtySession {
export interface LoginPtySession {
/**
* Registers the one output listener. The session streams each raw terminal
* output chunk to `listener`, in order, as the pseudo-terminal emits it.
@@ -49,7 +49,7 @@ export interface SetupTokenPtySession {
* Opens a pseudo-terminal session for `command`. A provider binds this to its
* sandbox. The transport calls it one time, on start.
*/
export type SetupTokenPtySessionOpener = (command: string) => Promise<SetupTokenPtySession>;
export type LoginPtySessionOpener = (command: string) => Promise<LoginPtySession>;
/**
* The child side of the setup-token run, in the shape the login runner needs.
@@ -60,7 +60,7 @@ export type SetupTokenPtySessionOpener = (command: string) => Promise<SetupToken
* accepts the transport with no adapter. The transport never imports the runner,
* so the runner package keeps its one-way dependency on this package.
*/
export interface SetupTokenPtyTransport {
export interface LoginPtyTransport {
/**
* Opens the pseudo-terminal session for `command` and streams the terminal
* output to `onData` in order. Resolves with the child exit code when the
@@ -86,15 +86,15 @@ export interface SetupTokenPtyTransport {
}
/**
* Creates a {@link SetupTokenPtyTransport} over `open`. The transport adapts a
* Creates a {@link LoginPtyTransport} over `open`. The transport adapts a
* pseudo-terminal session into the runner driver shape. It buffers an early
* input write, it honors an early stop, and it forwards the raw terminal bytes
* with no ANSI or OSC 8 handling.
*/
export function createSetupTokenPtyTransport(
open: SetupTokenPtySessionOpener,
): SetupTokenPtyTransport {
let session: SetupTokenPtySession | null = null;
export function createLoginPtyTransport(
open: LoginPtySessionOpener,
): LoginPtyTransport {
let session: LoginPtySession | null = null;
let started = false;
let stopped = false;
// Input that the runner writes before the session opens. The transport flushes
@@ -113,7 +113,7 @@ export function createSetupTokenPtyTransport(
return {
async start(command, onData): Promise<{ exitCode: number | null }> {
if (started) {
throw new Error("setup-token PTY transport already started.");
throw new Error("login PTY transport already started.");
}
started = true;
const opened = await open(command);
@@ -19,6 +19,14 @@ const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
// The default cap on the aggregate raw bytes the in-sandbox duplex frame decoder
// retains between chunks. The generated gateway runs in a separate operating-system
// process, so it cannot share the host aggregate byte ledger. It enforces this
// separate cap locally under the `sandbox_process` scope, and the provider memory
// allocation bounds it. The host passes the value through
// PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES. The default is well above one maximum
// frame, so a legitimate single frame never trips it.
const DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES = 8 * 1024 * 1024;
// Per-iteration timeout for one poll-loop client call. A healthy control-plane
// round trip finishes in well under one second, so 10s is far above a normal
// iteration and never false-fires on a slow-but-live call. It is also well
@@ -89,6 +97,18 @@ const CALLBACK_BRIDGE_WORKER_FAILED_SPAN = "sandbox.callbackBridge.workerFailed"
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
/**
* The default cap on the aggregate raw bytes the generated in-sandbox duplex frame
* decoder retains. The host passes it through
* `PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES`. The in-sandbox decoder enforces the
* cap locally under the `sandbox_process` scope; it never touches the host
* aggregate byte ledger.
*/
export const DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES = DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES;
/** The scope label the in-sandbox duplex decoder reports for its local byte counter. */
export const SANDBOX_DUPLEX_DECODER_SCOPE = "sandbox_process";
export interface SandboxCallbackBridgeRouteRule {
method: string;
path: RegExp;
@@ -1743,9 +1763,12 @@ export async function startSandboxCallbackBridgeServer(input: {
* compatible with the host codec. {@link getSandboxDuplexGatewayCodecSource}
* returns this exact source so a test can run every fixture vector against it.
*/
const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 1;
const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 2;
const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024;
const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000;
const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256;
const DEFAULT_MAX_DUPLEX_DECODER_BYTES = ${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES};
const DUPLEX_DECODER_SCOPE = "${SANDBOX_DUPLEX_DECODER_SCOPE}";
const DUPLEX_NEWLINE_BYTE = 0x0a;
const DUPLEX_EMPTY = Buffer.alloc(0);
const DUPLEX_RESPONSE_OUTCOMES = new Set(["completed", "indeterminate", "unavailable"]);
@@ -1770,10 +1793,23 @@ function duplexIsStringRecord(value) {
return true;
}
function duplexIsSafeNonNegativeInteger(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function encodeDuplexFrame(frame) {
return JSON.stringify(frame) + "\\n";
}
function encodeDuplexFrameChecked(frame, maxFrameBytes) {
const limit = maxFrameBytes != null ? maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES;
const json = JSON.stringify(frame);
if (Buffer.byteLength(json, "utf8") > limit) {
return { ok: false, error: { code: "frame_too_large", message: "frame exceeds the maximum size" } };
}
return { ok: true, line: json + "\\n" };
}
function decodeDuplexLine(line) {
const text = typeof line === "string" ? line : line.toString("utf8");
let parsed;
@@ -1797,6 +1833,8 @@ function duplexValidateFrame(frame) {
return duplexValidateRequest(frame);
case "response":
return duplexValidateResponse(frame);
case "body_chunk":
return duplexValidateBodyChunk(frame);
case "ready":
return duplexValidateReady(frame);
case "heartbeat":
@@ -1816,7 +1854,7 @@ function duplexValidateRequest(frame) {
typeof frame.method !== "string" ||
typeof frame.path !== "string" ||
typeof frame.query !== "string" ||
typeof frame.body !== "string" ||
!duplexIsSafeNonNegativeInteger(frame.bodyByteCount) ||
!duplexIsStringRecord(frame.headers)
) {
return duplexFail("malformed_frame", "request frame has a missing or wrong-typed field");
@@ -1831,7 +1869,7 @@ function duplexValidateResponse(frame) {
if (
typeof frame.id !== "string" ||
typeof frame.status !== "number" ||
typeof frame.body !== "string" ||
!duplexIsSafeNonNegativeInteger(frame.bodyByteCount) ||
!duplexIsStringRecord(frame.headers) ||
typeof frame.outcome !== "string" ||
!DUPLEX_RESPONSE_OUTCOMES.has(frame.outcome)
@@ -1844,6 +1882,19 @@ function duplexValidateResponse(frame) {
return duplexOk(frame);
}
function duplexValidateBodyChunk(frame) {
if (typeof frame.id !== "string" || typeof frame.data !== "string") {
return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed field");
}
if (!duplexIsSafeNonNegativeInteger(frame.seq)) {
return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq");
}
if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) {
return duplexFail("id_too_large", "body_chunk frame id exceeds the maximum size");
}
return duplexOk(frame);
}
function duplexValidateReady(frame) {
if (typeof frame.nonce !== "string") {
return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed nonce");
@@ -1872,10 +1923,32 @@ class DuplexFrameDecoder {
this.discarding = false;
this.maxFrameBytes =
options && options.maxFrameBytes != null ? options.maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES;
// The in-sandbox decoder runs in a separate operating-system process, so it
// cannot share the host aggregate byte ledger. It bounds its own retained
// bytes with a local cap under the "sandbox_process" scope. The counters here
// never increment or release a host aggregate token.
this.scope = DUPLEX_DECODER_SCOPE;
this.maxAggregateBytes =
options && options.maxAggregateBytes != null
? options.maxAggregateBytes
: DEFAULT_MAX_DUPLEX_DECODER_BYTES;
this.bytesInUse = 0;
this.aggregateRejections = 0;
}
push(chunk) {
const incoming = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
// Enforce the local sandbox-process cap before the concat allocates the peak
// "old + incoming" buffer. A rejection fails closed: the decoder drops the
// retained buffer and the incoming chunk, resynchronizes at the next newline,
// and reports the aggregate rejection. It retains nothing over the cap.
if (this.buffer.length + incoming.length > this.maxAggregateBytes) {
this.buffer = DUPLEX_EMPTY;
this.discarding = true;
this.bytesInUse = 0;
this.aggregateRejections += 1;
return [duplexFail("aggregate_bytes_exceeded", "aggregate retained bytes exceeded the sandbox-process cap")];
}
this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
const results = [];
for (;;) {
@@ -1907,6 +1980,8 @@ class DuplexFrameDecoder {
}
results.push(decodeDuplexLine(line));
}
// Reconcile the local sandbox-process counter to the bytes still retained.
this.bytesInUse = this.buffer.length;
return results;
}
}`;
@@ -1915,9 +1990,10 @@ class DuplexFrameDecoder {
* Return the exact zero-dependency codec source the generated duplex gateway
* embeds. A test runs every fixture vector against this source, so it proves the
* embedded copy decodes the same bytes as the host codec. The source declares
* `encodeDuplexFrame`, `decodeDuplexLine`, `DuplexFrameDecoder`,
* `DUPLEX_FRAME_VERSION`, and `DEFAULT_MAX_DUPLEX_FRAME_BYTES`, but exports none
* of them; a caller wraps it to read those names.
* `encodeDuplexFrame`, `encodeDuplexFrameChecked`, `decodeDuplexLine`,
* `DuplexFrameDecoder`, `DUPLEX_FRAME_VERSION`, and
* `DEFAULT_MAX_DUPLEX_FRAME_BYTES`, but exports none of them; a caller wraps it to
* read those names.
*/
export function getSandboxDuplexGatewayCodecSource(): string {
return DUPLEX_GATEWAY_CODEC_SOURCE;
@@ -1949,6 +2025,12 @@ const responseTimeoutMs = Number(
);
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
// The host passes the separate sandbox-process raw-decoder cap here. The in-sandbox
// decoder enforces it locally under the "sandbox_process" scope; it never shares the
// host aggregate byte ledger.
const maxDuplexDecoderBytes = Number(
process.env.PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES || "${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}",
);
const heartbeatIntervalMs = Number(
process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}",
);
@@ -2136,10 +2218,37 @@ async function runFileGateway() {
});
}
// Split one whole body buffer into body_chunk frames. The gateway buffers the
// whole source body once, then splits it here. Each frame carries one fixed raw
// slice as base64 text, except the final frame, which carries the remaining
// bytes. A zero-length body yields no frame. Send-side true streaming is a
// separate goal; this whole-body split is acceptable for this gateway.
function splitDuplexBodyIntoChunks(id, body) {
const frames = [];
let seq = 0;
for (let offset = 0; offset < body.length; offset += DUPLEX_BODY_CHUNK_RAW_BYTES) {
const slice = body.subarray(offset, offset + DUPLEX_BODY_CHUNK_RAW_BYTES);
frames.push({
version: DUPLEX_FRAME_VERSION,
type: "body_chunk",
id: id,
seq: seq,
data: slice.toString("base64"),
});
seq += 1;
}
return frames;
}
function runDuplexGateway() {
// One outstanding local request per id. Each entry holds the HTTP resolver and
// the wait-budget timer.
const pending = new Map();
// One in-flight response reassembly per id. The host returns a response as an
// envelope frame that carries bodyByteCount, then the body_chunk frames that
// carry the body. The gateway reassembles the response body in memory here; it
// does not spill, because a production response body stays small.
const responseAssembly = new Map();
let unavailable = false;
let lossTriggered = false;
let lastInboundAt = Date.now();
@@ -2181,27 +2290,102 @@ function runDuplexGateway() {
});
}
pending.clear();
responseAssembly.clear();
const exitTimer = setTimeout(() => process.exit(0), lossExitGraceMs);
if (typeof exitTimer.unref === "function") exitTimer.unref();
}
// Fail one outstanding request with a bounded local 502. The gateway calls it
// when a response reassembly breaks: a reordered seq, a base64 that is not
// canonical, or a total that overruns the declared body size. The host is the
// response peer, so this is a defensive local error, not a channel loss.
function failRequest(id, message) {
responseAssembly.delete(id);
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
clearTimeout(entry.timer);
entry.resolve({
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: message }),
});
}
function handleInboundFrame(frame) {
if (frame.type === "response") {
const entry = pending.get(frame.id);
if (!entry) return;
pending.delete(frame.id);
clearTimeout(entry.timer);
// Map an indeterminate outcome to a non-retryable 409, the same contract
// the file gateway applies through the outcome header.
const statusCode =
frame.outcome === "indeterminate" ? 409 : typeof frame.status === "number" ? frame.status : 200;
entry.resolve({
const headers = frame.headers || {};
if (frame.bodyByteCount === 0) {
// A zero-length body accepts no body_chunk, so the response completes now.
pending.delete(frame.id);
responseAssembly.delete(frame.id);
clearTimeout(entry.timer);
entry.resolve({ status: statusCode, headers: headers, body: "" });
return;
}
// The body rides body_chunk frames that share this id. Record the envelope
// and wait for the chunks.
responseAssembly.set(frame.id, {
status: statusCode,
headers: frame.headers || {},
body: typeof frame.body === "string" ? frame.body : "",
headers: headers,
bodyByteCount: frame.bodyByteCount,
received: 0,
nextSeq: 0,
chunks: [],
});
return;
}
if (frame.type === "body_chunk") {
const asm = responseAssembly.get(frame.id);
if (!asm) return;
if (frame.seq !== asm.nextSeq) {
failRequest(frame.id, "duplex response body_chunk seq is out of order");
return;
}
const decoded = Buffer.from(frame.data, "base64");
if (decoded.toString("base64") !== frame.data) {
failRequest(frame.id, "duplex response body_chunk is not canonical base64");
return;
}
if (decoded.length === 0) {
failRequest(frame.id, "duplex response body_chunk is empty");
return;
}
if (asm.received + decoded.length > asm.bodyByteCount) {
failRequest(frame.id, "duplex response body overruns the declared size");
return;
}
const isFinal = asm.received + decoded.length === asm.bodyByteCount;
if (
decoded.length > DUPLEX_BODY_CHUNK_RAW_BYTES ||
(!isFinal && decoded.length !== DUPLEX_BODY_CHUNK_RAW_BYTES)
) {
failRequest(frame.id, "duplex response body_chunk has the wrong size");
return;
}
asm.nextSeq += 1;
asm.received += decoded.length;
asm.chunks.push(decoded);
if (asm.received === asm.bodyByteCount) {
const entry = pending.get(frame.id);
responseAssembly.delete(frame.id);
if (!entry) return;
pending.delete(frame.id);
clearTimeout(entry.timer);
entry.resolve({
status: asm.status,
headers: asm.headers,
body: Buffer.concat(asm.chunks).toString("utf8"),
});
}
return;
}
if (frame.type === "close") {
triggerLoss("host sent a close frame");
return;
@@ -2210,7 +2394,7 @@ function runDuplexGateway() {
// local action here.
}
const decoder = new DuplexFrameDecoder();
const decoder = new DuplexFrameDecoder({ maxAggregateBytes: maxDuplexDecoderBytes });
process.stdin.on("data", (chunk) => {
lastInboundAt = Date.now();
for (const result of decoder.push(chunk)) {
@@ -2258,11 +2442,14 @@ function runDuplexGateway() {
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const requestBodyBuffer = Buffer.from(await readBody(req), "utf8");
if (unavailable) {
writeJsonResponse(res, 503, { error: "bridge_unavailable" });
return;
}
// The request body rides body_chunk frames. The envelope carries only the
// raw byte count, so the envelope stays small and the body splits into
// fixed-size slices that each stay under the frame bound.
const requestFrame = {
version: DUPLEX_FRAME_VERSION,
type: "request",
@@ -2271,10 +2458,36 @@ function runDuplexGateway() {
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
bodyByteCount: requestBodyBuffer.length,
};
const encodedRequest = encodeDuplexFrameChecked(requestFrame);
if (!encodedRequest.ok) {
writeJsonResponse(res, 413, { error: "request_too_large" });
return;
}
// Pre-encode every body_chunk frame and enforce the frame size bound on
// each. A fixed raw slice never exceeds the bound, so this guard is
// defensive. On a rejection, fail this one local request with a clean 413.
// The frames never leave the gateway, so no other in-flight request is
// affected and the channel stays open.
const chunkFrames = splitDuplexBodyIntoChunks(requestId, requestBodyBuffer);
const encodedChunks = [];
let chunkTooLarge = false;
for (const chunk of chunkFrames) {
const encodedChunk = encodeDuplexFrameChecked(chunk);
if (!encodedChunk.ok) {
chunkTooLarge = true;
break;
}
encodedChunks.push(encodedChunk.line);
}
if (chunkTooLarge) {
writeJsonResponse(res, 413, { error: "request_too_large" });
return;
}
const response = await new Promise((resolve) => {
const timer = setTimeout(() => {
responseAssembly.delete(requestId);
if (pending.delete(requestId)) {
resolve({
status: 502,
@@ -2284,7 +2497,8 @@ function runDuplexGateway() {
}
}, responseTimeoutMs);
pending.set(requestId, { resolve: resolve, timer: timer });
writeFrame(requestFrame);
process.stdout.write(encodedRequest.line);
for (const line of encodedChunks) process.stdout.write(line);
});
res.statusCode = typeof response.status === "number" ? response.status : 200;
for (const [key, value] of Object.entries(response.headers || {})) {
@@ -42,6 +42,62 @@ describe("parseDeviceLoginPrompt", () => {
expect(result?.code).toBe("ABCD-EFGHJ");
});
it("parse_returns_url_and_code_from_carriage_return_line_endings", () => {
// A pseudo-terminal ends each line with a carriage return and a line feed.
// The carriage return is whitespace, so the URL token stops before it, and the
// code line trims it. The parser reads the tokens the same as line-feed output.
const text = [
"1. Open this link in your browser and sign in to your account",
EXACT_URL,
"2. Enter this one-time code (expires in 15 minutes)",
"ABCD-EFGHJ",
].join("\r\n");
const result = parseDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe(EXACT_URL);
expect(result?.code).toBe("ABCD-EFGHJ");
});
it("parse_returns_url_and_code_with_a_terminal_title_osc_sequence", () => {
// A pseudo-terminal can emit an Operating System Command (OSC) title sequence
// in the stream. The device-auth prompt still prints the URL and the code as
// plain standalone tokens on their own lines. The OSC title sequence carries no
// URL token, so it does not shift the URL or the code, and the parser reads
// both tokens.
const osc = "\x1b]0;Codex\x07";
const text = [
`${osc}1. Open this link in your browser and sign in to your account`,
EXACT_URL,
"2. Enter this one-time code (expires in 15 minutes)",
"ABCD-EFGHJ",
].join("\n");
const result = parseDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe(EXACT_URL);
expect(result?.code).toBe("ABCD-EFGHJ");
});
it("parse_returns_url_and_code_from_a_merged_raw_pseudo_terminal_frame", () => {
// One buffer holds the whole prompt as a real pseudo-terminal emits it: a
// leading title Operating System Command (OSC) sequence, color Control
// Sequence Introducer (CSI) sequences around the URL and the code, and
// carriage-return line endings. The parser removes the color sequences and
// reads the plain tokens.
const title = "\x1b]0;Codex login\x07";
const cyan = "\x1b[36m";
const bold = "\x1b[1m";
const reset = "\x1b[0m";
const text =
`${title}1. Open this link in your browser and sign in to your account\r\n` +
`${cyan}${EXACT_URL}${reset}\r\n` +
`2. Enter this one-time code (expires in 15 minutes)\r\n` +
`${bold}ABCD-EFGHJ${reset}\r\n`;
const result = parseDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe(EXACT_URL);
expect(result?.code).toBe("ABCD-EFGHJ");
});
it("parse_returns_null_when_prompt_absent", () => {
const text = "Some unrelated log line\nNothing to see here\n";
expect(parseDeviceLoginPrompt(text)).toBeNull();
@@ -24,10 +24,10 @@ interface FakeDriverOptions {
function createFakeDriver(options: FakeDriverOptions = {}) {
const disposeCalls = { count: 0 };
const driver: SandboxLoginDriver = {
async execStreaming(_command, onStdout) {
async start(_command, onData) {
if (options.execError) throw options.execError;
for (const chunk of options.chunks ?? []) {
onStdout(chunk);
onData(chunk);
}
if (options.hang) {
// Never resolve. The runner must fall back to its timeout or signal.
@@ -38,18 +38,33 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024;
/**
* The sandbox side of the device-login run. The runner never calls Daytona
* directly; a caller injects a concrete driver. A production driver binds these
* three methods to a non-persisting Daytona exec path, a file read, and a
* sandbox delete.
* directly; a caller injects a concrete driver. A production driver binds `start`
* to the shared login pseudo-terminal (PTY) transport, binds `readFile` to a
* descriptor-bound credential read, and binds `dispose` to the transport dispose.
*
* The `start` shape matches the shared login pseudo-terminal transport start
* method, so the driver runs the login command on a real pseudo-terminal. The
* login command
* needs a pseudo-terminal: pipe stdio emits no login prompt. The runner never
* calls `stop` or `write` on the driver; the device-login flow needs no delayed
* input, and the service owns the sandbox delete. The runner disposes the driver
* one time on every terminal state.
*/
export interface SandboxLoginDriver extends LoginRunnerDisposable {
/**
* Runs `command` in the sandbox and streams standard output to `onStdout` in
* memory. Resolves with the command exit code when the command ends. A driver
* must not persist the raw output to any durable log.
* Starts `command` on a pseudo-terminal and streams the terminal output to
* `onData` in memory, in order, as the pseudo-terminal emits it. Resolves with
* the command exit code when the command ends. A driver must not persist the raw
* output to any durable log.
*/
start(command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }>;
/**
* Reads the credential bytes with one descriptor-bound read. The read is
* separate from the pseudo-terminal session; the session has no file-read
* method. A driver runs one fixed, server-controlled operation that opens the
* verified session home and the credential file with no symlink follow, checks
* the opened descriptor, and reads only from that same descriptor.
*/
execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>;
/** Reads the bytes of one file from the sandbox. */
readFile(path: string): Promise<Buffer>;
}
@@ -123,8 +138,8 @@ export async function runDeviceLogin(
return { outcome: "cancelled", exitCode: null, promptSurfaced };
}
const exec = driver.execStreaming(command, onStdout);
const raced = await raceLoginRunnerExit(exec, timeoutMs, signal);
const started = driver.start(command, onStdout);
const raced = await raceLoginRunnerExit(started, timeoutMs, signal);
if (raced.kind === "timeout") {
log("[paperclip] Device login timed out; disposing the sandbox.");
@@ -42,7 +42,7 @@
],
"scripts": {
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "rm -rf dist && tsc",
"build": "rm -rf dist && tsc && mkdir -p dist/scripts && cp -R src/scripts/. dist/scripts/",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
@@ -7,6 +7,7 @@ import {
type DaytonaPtyHandle,
type DaytonaPtyProcess,
} from "./duplex-command-stream.js";
import { PTY_INPUT_CHUNK_BYTES, PTY_MESSAGE_CAP_BYTES } from "./pty-chunked-input.js";
// The carriage return that submits the launch wrapper line to the terminal.
const ENTER = "\r";
@@ -273,6 +274,8 @@ describe("openDaytonaDuplexChannelSession", () => {
// The launch wrapper is the first input; the host frame write is the second.
session.write('{"version":1,"type":"heartbeat"}\n');
// The chunker awaits each send, so let the write settle before the assertion.
await new Promise((resolve) => setImmediate(resolve));
expect(process.handle?.inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n');
// The fake terminal echoes nothing, so the write produces no data callback.
// A real terminal in raw mode with echo off behaves the same way.
@@ -357,22 +360,104 @@ describe("openDaytonaDuplexChannelSession", () => {
expect(received).toEqual(['{"version":1,"type":"heartbeat"}\n']);
});
it("resolves wait with the gateway exit code", async () => {
it("maps a numeric SDK exit code to a process exit", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
process.handle?.finish(3);
await expect(session.wait()).resolves.toEqual({ exitCode: 3 });
// A numeric exit code is a real process exit, not a transport close.
await expect(session.wait()).resolves.toEqual({ exitCode: 3, transportClosed: false });
});
it("maps an absent exit code to null", async () => {
it("maps an absent SDK exit code to a transport close", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
process.handle?.finish(undefined);
await expect(session.wait()).resolves.toEqual({ exitCode: null });
// A non-numeric SDK result marks a reason-less transport close with no exit
// data, so the seam reports a transport close, not a process exit.
await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true });
});
it("splits a write above the provider cap into more than one send under the cap", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
const handle = process.handle;
if (!handle) throw new Error("The fake process opened no handle.");
// The launch wrapper is the first recorded input. Count only the write's sends.
const before = handle.inputs.length;
// A fixed payload well above the provider message cap. It stays fixed, so a
// raised chunk size above this size makes the write one send and fails this
// test. The payload is ASCII, so the fake's per-send decode keeps each byte.
const payload = "x".repeat(150_416);
session.write(payload);
// The chunker awaits each send, so let the write settle before the assertion.
await new Promise((resolve) => setImmediate(resolve));
const sends = handle.inputs.slice(before);
expect(sends.length).toBeGreaterThan(1);
for (const chunk of sends) {
expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(PTY_INPUT_CHUNK_BYTES);
expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(PTY_MESSAGE_CAP_BYTES);
}
// The sends rejoin to the exact payload, so the chunker loses no byte.
expect(sends.join("")).toBe(payload);
});
it("sends a write below the provider cap as exactly one send", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
const handle = process.handle;
if (!handle) throw new Error("The fake process opened no handle.");
const before = handle.inputs.length;
session.write('{"version":1,"type":"heartbeat"}\n');
// The chunker awaits each send, so let the write settle before the assertion.
await new Promise((resolve) => setImmediate(resolve));
expect(handle.inputs.slice(before)).toHaveLength(1);
});
it("keeps two writes in order and never interleaves their chunks", async () => {
// A handle whose `sendInput` yields a microtask before it records the chunk.
// The yield opens a window where a second write could jump ahead. The session
// chains the writes, so the second write starts only after the first ends.
const inputs: string[] = [];
const handle: DaytonaPtyHandle = {
async waitForConnection(): Promise<void> {},
async sendInput(data: string | Uint8Array): Promise<void> {
await Promise.resolve();
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
},
wait(): Promise<{ exitCode?: number; error?: string }> {
return new Promise(() => {});
},
async kill(): Promise<void> {},
async disconnect(): Promise<void> {},
};
const process: DaytonaPtyProcess = {
async createPty(): Promise<DaytonaPtyHandle> {
return handle;
},
};
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
// Two payloads above the cap, so each write is more than one chunk. "a" marks
// the first write and "b" marks the second write.
const first = "a".repeat(150_416);
const second = "b".repeat(150_416);
session.write(first);
session.write(second);
// Let both writes settle.
await new Promise((resolve) => setImmediate(resolve));
// Drop the launch wrapper (the first input) and rejoin the chunks. The rejoin
// equals the first payload then the second payload, so no chunk of the second
// write lands between the chunks of the first write.
expect(inputs.slice(1).join("")).toBe(`${first}${second}`);
});
it("kills the child on stop and releases the socket on close", async () => {
@@ -475,6 +560,55 @@ describe("openDaytonaDuplexChannelSession", () => {
expect(writeErrors).toEqual(["write_error"]);
expect(killed).toBe(1);
});
it("does not send a queued write after a first-send rejection terminalizes the channel", async () => {
// Two writes queue on the chain. The launch wrapper write succeeds, then the
// first frame write rejects and terminalizes the channel. The second queued
// write must not reach `sendInput`, so no write runs on the closed transport.
const sends: string[] = [];
let killed = 0;
const handle: DaytonaPtyHandle = {
async waitForConnection(): Promise<void> {},
async sendInput(data: string | Uint8Array): Promise<void> {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
sends.push(text);
// The launch wrapper write (the first send) succeeds. The first frame
// write (the second send) rejects and terminalizes the channel.
if (sends.length === 1) return;
throw new Error("broken");
},
wait(): Promise<{ exitCode?: number; error?: string }> {
return new Promise(() => {});
},
async kill(): Promise<void> {
killed += 1;
},
async disconnect(): Promise<void> {},
};
const process: DaytonaPtyProcess = {
async createPty(): Promise<DaytonaPtyHandle> {
return handle;
},
};
const writeErrors: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY, {
onWriteError: (reason) => writeErrors.push(reason),
});
session.write("first\n");
session.write("second\n");
// Let the write chain settle both queued writes.
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
// The channel terminalized one time on the first frame write. The second
// queued write sent no chunk, so exactly two sends ran: the launch wrapper and
// the first frame write. The second frame never reached the transport.
expect(writeErrors).toEqual(["write_error"]);
expect(killed).toBe(1);
expect(sends).toHaveLength(2);
expect(sends.some((text) => text.includes("second"))).toBe(false);
});
});
describe("createDaytonaDuplexChannelSessionOpener", () => {
@@ -27,7 +27,7 @@
// Dependency boundary: this provider plugin ships standalone (the workspace
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
// workspace package. It reuses the narrow Daytona PTY surface that
// `setup-token-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`,
// `login-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`,
// `DaytonaPtyCreateOptions`), so a caller passes `sandbox.process` with no
// adapter. The narrow surface keeps the module unit-testable with a fake PTY.
@@ -37,13 +37,15 @@ import type {
DaytonaPtyCreateOptions,
DaytonaPtyHandle,
DaytonaPtyProcess,
} from "./setup-token-pty.js";
} from "./login-pty.js";
export type {
DaytonaPtyCreateOptions,
DaytonaPtyHandle,
DaytonaPtyProcess,
} from "./setup-token-pty.js";
} from "./login-pty.js";
import { sendPtyInputInChunks } from "./pty-chunked-input.js";
/**
* A live duplex channel session for one command. The session allocates a real
@@ -56,8 +58,13 @@ export interface DuplexChannelSession {
onData(listener: (chunk: string) => void): void;
/** Writes raw input bytes to the pseudo-terminal. */
write(data: string): void;
/** Resolves with the child exit code when the command ends. */
wait(): Promise<{ exitCode: number | null }>;
/**
* Resolves when the command ends or the transport closes. A numeric `exitCode`
* is a real process exit. `transportClosed` is true when the pseudo-terminal
* socket closed with no exit data, so the caller can tell a real process exit
* from a reason-less transport close.
*/
wait(): Promise<{ exitCode: number | null; transportClosed: boolean }>;
/** Stops the child process. Safe to call more than one time. */
kill(): void;
/** Releases the session resources. Safe to call more than one time. */
@@ -203,14 +210,28 @@ export async function openDaytonaDuplexChannelSession(
// Report a host-to-sandbox write failure one time and end the channel at once.
// The seam carries only the typed reason; the raw provider error never leaves
// this scope. The channel end propagates the loss up through the exit.
//
// `channelTerminated` turns true on the first rejected chunk, before the early
// return, so a later queued write reads it and sends no chunk. Without the flag
// a queued write runs after the terminal kill and calls `sendInput` on the
// closed transport.
let channelTerminated = false;
let writeErrorReported = false;
const endOnWriteError = (): void => {
channelTerminated = true;
if (writeErrorReported) return;
writeErrorReported = true;
options?.onWriteError?.(DAYTONA_DUPLEX_WRITE_ERROR_REASON);
void handle.kill().catch(() => undefined);
};
// The tail of the write chain. The chunker awaits each chunk, so one write can
// suspend between its chunks. The chain runs each write after the previous
// write ends, so the chunks of two writes never interleave on the wire. The
// chain never rejects, because the chunker maps a rejected send to
// `endOnWriteError` and returns.
let writeChain: Promise<void> = Promise.resolve();
return {
onData(next: (chunk: string) => void): void {
listener = next;
@@ -221,17 +242,38 @@ export async function openDaytonaDuplexChannelSession(
}
},
write(data: string): void {
// Fire the input write. A write error must not throw into the transport, so
// the transport's stream stays the single result path. On a rejected write,
// end the channel at once and report the typed reason. The raw provider
// error never reaches a sink.
void handle.sendInput(data).catch(() => {
endOnWriteError();
// Send the input as byte-bounded chunks under the provider message cap. A
// whole payload in one message can cross the cap and take the channel down,
// so the chunker slices the payload and sends each chunk in order. The chain
// runs this write after the previous write ends, so two writes keep their
// order and never interleave their chunks. A write error must not throw into
// the transport, so the transport's stream stays the single result path. On
// a rejected chunk, the chunker ends the channel one time through
// `endOnWriteError` and sends no later chunk. The raw provider error never
// reaches a sink.
//
// A rejected chunk terminalizes the channel and kills the transport. So this
// write skips the send when `channelTerminated` is true, and a queued write
// never calls `sendInput` on the closed transport.
writeChain = writeChain.then(() => {
if (channelTerminated) return;
return sendPtyInputInChunks(
(chunk) => handle.sendInput(chunk),
data,
endOnWriteError,
);
});
},
async wait(): Promise<{ exitCode: number | null }> {
async wait(): Promise<{ exitCode: number | null; transportClosed: boolean }> {
const result = await handle.wait();
return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null };
if (typeof result.exitCode === "number") {
// A numeric exit code is a real process exit. The SDK parses it from the
// pseudo-terminal WebSocket close reason (for example `{"exitCode":0}`).
return { exitCode: result.exitCode, transportClosed: false };
}
// A non-numeric exit code marks a reason-less transport close: the socket
// closed with no exit data. It is a transport close, not a process exit.
return { exitCode: null, transportClosed: true };
},
kill(): void {
void handle.kill().catch(() => undefined);
@@ -0,0 +1,645 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
composeLaunchLine,
createDaytonaLoginHomeFs,
createDaytonaLoginPtySessionOpener,
encodePosixShellArg,
openDaytonaLoginPtySession,
type DaytonaExecResult,
type DaytonaLoginHomeFs,
type DaytonaPtyCreateOptions,
type DaytonaPtyHandle,
type DaytonaPtyProcess,
type DaytonaSandboxExec,
type LoginHomeInspection,
type LoginPtyLaunchDescriptor,
} from "./login-pty.js";
// The Enter byte the terminal login UI reads to submit the browser code.
const ENTER = "\r";
const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555";
const LOGIN_UID = 1000;
const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME };
const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME };
/** A directory entry in the virtual filesystem. */
type FakeEntry = Omit<LoginHomeInspection, "exists">;
const ABSENT: LoginHomeInspection = {
exists: false,
isSymlink: false,
isDirectory: false,
mode: "",
ownerUid: null,
};
/**
* A fake login-home filesystem. It models one virtual path. `createDirectory`
* fails when the path exists and otherwise stores `createAs` (a clean 0700
* directory owned by the login user by default). A test seeds a pre-existing
* entry, forces a bad created entry, or swaps the entry through `onCreatePty`, so
* a test drives each rejection and the pre-launch re-check.
*/
function createFakeHomeFs(config?: {
loginUid?: number;
seed?: FakeEntry;
createAs?: FakeEntry;
createShouldFail?: boolean;
}): DaytonaLoginHomeFs & {
created: Array<{ path: string; mode: string }>;
inspectCount: number;
setEntry: (entry: FakeEntry | null) => void;
} {
const loginUid = config?.loginUid ?? LOGIN_UID;
const cleanDir: FakeEntry = {
isSymlink: false,
isDirectory: true,
mode: "700",
ownerUid: loginUid,
};
let entry: FakeEntry | null = config?.seed ?? null;
const created: Array<{ path: string; mode: string }> = [];
const state = { inspectCount: 0 };
return {
created,
get inspectCount() {
return state.inspectCount;
},
setEntry(next: FakeEntry | null): void {
entry = next;
},
async loginUserId(): Promise<number> {
return loginUid;
},
async inspect(): Promise<LoginHomeInspection> {
state.inspectCount += 1;
return entry ? { exists: true, ...entry } : ABSENT;
},
async createDirectory(path: string, mode: string): Promise<void> {
if (config?.createShouldFail || entry) {
throw new Error("LOGIN_PTY_HOME_REJECTED");
}
created.push({ path, mode });
entry = config?.createAs ?? cleanDir;
},
};
}
/**
* A fake Daytona PTY handle. It records each input write, drives the output
* stream on demand, and records the kill and the disconnect. The tests use it in
* place of the real SDK `PtyHandle`, so the session runs with no sandbox.
*/
function createFakePtyHandle(
onData: (data: Uint8Array) => void | Promise<void>,
onWaitForConnection?: () => void,
): DaytonaPtyHandle & {
inputs: string[];
emitText: (text: string) => void;
emitBytes: (bytes: Uint8Array) => void;
finish: (exitCode: number | undefined) => void;
killed: number;
disconnected: number;
} {
const encoder = new TextEncoder();
const inputs: string[] = [];
let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null;
const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => {
resolveWait = resolve;
});
let killed = 0;
let disconnected = 0;
return {
async waitForConnection(): Promise<void> {
onWaitForConnection?.();
},
async sendInput(data: string | Uint8Array): Promise<void> {
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
},
wait(): Promise<{ exitCode?: number; error?: string }> {
return waitPromise;
},
async kill(): Promise<void> {
killed += 1;
},
async disconnect(): Promise<void> {
disconnected += 1;
},
emitText(text: string): void {
onData(encoder.encode(text));
},
emitBytes(bytes: Uint8Array): void {
onData(bytes);
},
finish(exitCode: number | undefined): void {
resolveWait?.({ exitCode });
},
get inputs(): string[] {
return inputs;
},
get killed(): number {
return killed;
},
get disconnected(): number {
return disconnected;
},
};
}
/**
* A fake Daytona process. It opens one fake PTY handle and records the create
* options, so a test asserts the terminal size and the launch line. `onCreatePty`
* runs when the session opens the terminal, so a test swaps a symlink in after
* the directory creation but before the launch input.
*/
function createFakeProcess(onCreatePty?: () => void): DaytonaPtyProcess & {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
createCount: number;
} {
const state: {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
createCount: number;
} = { handle: null, createOptions: null, createCount: 0 };
return {
get handle() {
return state.handle;
},
get createOptions() {
return state.createOptions;
},
get createCount() {
return state.createCount;
},
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
state.createCount += 1;
state.createOptions = options;
onCreatePty?.();
const handle = createFakePtyHandle(options.onData);
state.handle = handle;
return handle;
},
};
}
describe("encodePosixShellArg", () => {
it("wraps a value in single quotes and neutralizes metacharacters", () => {
expect(encodePosixShellArg("/tmp/x")).toBe("'/tmp/x'");
// A metacharacter, a space, and a semicolon stay literal inside the quotes.
expect(encodePosixShellArg("; rm -rf / #")).toBe("'; rm -rf / #'");
// An embedded single quote closes, escapes, and reopens the quote.
expect(encodePosixShellArg("a'b")).toBe("'a'\\''b'");
expect(encodePosixShellArg("$(id)")).toBe("'$(id)'");
});
});
describe("composeLaunchLine", () => {
it("composes the Claude line with no CODEX_HOME", () => {
const line = composeLaunchLine(CLAUDE);
expect(line).toBe("exec claude setup-token");
expect(line).not.toContain("CODEX_HOME");
});
it("composes the Codex line with exactly one encoded CODEX_HOME", () => {
const line = composeLaunchLine(CODEX);
expect(line).toBe(`exec env CODEX_HOME='${HOME}' codex login --device-auth`);
// Exactly one CODEX_HOME assignment, and the exact approved Codex command.
expect(line.match(/CODEX_HOME=/g)).toHaveLength(1);
expect(line.endsWith("codex login --device-auth")).toBe(true);
});
});
describe("openDaytonaLoginPtySession — session home", () => {
it("creates the exact UUID directory as a new 0700 directory owned by the login user", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs();
await openDaytonaLoginPtySession(process, fs, CLAUDE);
// The provider created the exact directory as a new 0700 directory.
expect(fs.created).toEqual([{ path: HOME, mode: "700" }]);
// The terminal opened and the launch line ran.
expect(process.createCount).toBe(1);
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
});
it("sends the Codex launch line with the encoded CODEX_HOME", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs();
await openDaytonaLoginPtySession(process, fs, CODEX);
expect(process.handle?.inputs[0]).toBe(
`exec env CODEX_HOME='${HOME}' codex login --device-auth` + ENTER,
);
// The Claude line carries no CODEX_HOME; the Codex line carries exactly one.
expect((process.handle?.inputs[0]?.match(/CODEX_HOME=/g) ?? []).length).toBe(1);
});
it("rejects a pre-existing target before it creates the directory", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
seed: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
// The provider never created the directory and never opened the terminal.
expect(fs.created).toEqual([]);
expect(process.createCount).toBe(0);
});
it("rejects a pre-existing symlink at the target", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
seed: { isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
expect(process.createCount).toBe(0);
});
it("rejects a created target that is a symlink", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
createAs: { isSymlink: true, isDirectory: false, mode: "700", ownerUid: LOGIN_UID },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
expect(process.createCount).toBe(0);
});
it("rejects a created target that is not a directory", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
createAs: { isSymlink: false, isDirectory: false, mode: "700", ownerUid: LOGIN_UID },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
expect(process.createCount).toBe(0);
});
it("rejects a created directory with a wrong owner", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
createAs: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID + 1 },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
expect(process.createCount).toBe(0);
});
it("rejects a created directory with a wrong mode", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs({
createAs: { isSymlink: false, isDirectory: true, mode: "755", ownerUid: LOGIN_UID },
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
expect(process.createCount).toBe(0);
});
it("rejects a symlink swapped in after creation, before the launch input", async () => {
// The directory validates cleanly, then a symlink replaces it while the
// terminal opens. The no-symlink re-check before the launch input fails.
const fs = createFakeHomeFs();
const process = createFakeProcess(() => {
fs.setEntry({ isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID });
});
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
// The terminal opened, but the launch input never ran.
expect(process.createCount).toBe(1);
expect(process.handle?.inputs).toEqual([]);
});
it("rejects a descriptor with a command key outside the closed set", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs();
await expect(
openDaytonaLoginPtySession(process, fs, {
loginCommandKey: "gemini" as unknown as "claude",
sessionHome: HOME,
}),
).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED");
// The provider never touched the filesystem or the terminal.
expect(fs.created).toEqual([]);
expect(process.createCount).toBe(0);
});
it("rejects a descriptor whose session home shape is wrong", async () => {
const process = createFakeProcess();
const fs = createFakeHomeFs();
await expect(
openDaytonaLoginPtySession(process, fs, {
loginCommandKey: "codex",
sessionHome: "/tmp/paperclip-adapter-login/../etc",
}),
).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED");
expect(process.createCount).toBe(0);
});
});
describe("openDaytonaLoginPtySession — session mechanics", () => {
it("starts the login command on a pseudo-terminal with a fixed size", async () => {
const process = createFakeProcess();
await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
expect(process.createOptions?.cols).toBe(120);
expect(process.createOptions?.rows).toBe(30);
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
});
it("returns incremental terminal output to the listener", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
session.onData((chunk) => received.push(chunk));
process.handle?.emitText("the url below to sign in\n");
process.handle?.emitText("Paste code here if prompted\n");
expect(received).toEqual(["the url below to sign in\n", "Paste code here if prompted\n"]);
});
it("buffers early output until the listener registers", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
process.handle?.emitText("early output ");
process.handle?.emitText("more output");
expect(received).toEqual([]);
session.onData((chunk) => received.push(chunk));
expect(received).toEqual(["early output more output"]);
});
it("delivers the browser code plus the Enter byte to the command", async () => {
const process = createFakeProcess();
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
session.write("BROWSERCODE" + ENTER);
// The write runs through the serialized chunk chain, so it lands on a later
// microtask. Flush the chain before the assertion.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(process.handle?.inputs[1]).toBe("BROWSERCODE" + ENTER);
expect(process.handle?.inputs[1]?.endsWith(ENTER)).toBe(true);
});
it("keeps a multibyte character whole across two output chunks", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
session.onData((chunk) => received.push(chunk));
const euro = new TextEncoder().encode("€");
process.handle?.emitBytes(euro.subarray(0, 2));
process.handle?.emitBytes(euro.subarray(2));
expect(received.join("")).toBe("€");
});
it("resolves wait with the command exit code", async () => {
const process = createFakeProcess();
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
process.handle?.finish(9);
await expect(session.wait()).resolves.toEqual({ exitCode: 9 });
});
it("maps an absent exit code to null", async () => {
const process = createFakeProcess();
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
process.handle?.finish(undefined);
await expect(session.wait()).resolves.toEqual({ exitCode: null });
});
it("kills the child and closes the session", async () => {
const process = createFakeProcess();
const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE);
session.kill();
expect(process.handle?.killed).toBe(1);
await session.close();
expect(process.handle?.disconnected).toBe(1);
});
it("passes the working directory to the pseudo-terminal", async () => {
const process = createFakeProcess();
await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE, { cwd: "/workspace" });
expect(process.createOptions?.cwd).toBe("/workspace");
});
});
describe("openDaytonaLoginPtySession — ancestor symlink", () => {
it("starts no pseudo-terminal when the home filesystem check fails closed", async () => {
// A symlinked login-root ancestor makes the descriptor-relative inspection fail
// closed. The session opener must reject before it opens the terminal, so no
// pseudo-terminal starts and the login command never runs.
const process = createFakeProcess();
let created = false;
const fs: DaytonaLoginHomeFs = {
async loginUserId(): Promise<number> {
return LOGIN_UID;
},
async inspect(): Promise<LoginHomeInspection> {
throw new Error("LOGIN_PTY_HOME_REJECTED");
},
async createDirectory(): Promise<void> {
created = true;
},
};
await expect(openDaytonaLoginPtySession(process, fs, CODEX)).rejects.toThrow(
"LOGIN_PTY_HOME_REJECTED",
);
// The provider never created the directory and never opened the terminal.
expect(created).toBe(false);
expect(process.createCount).toBe(0);
});
});
describe("createDaytonaLoginHomeFs — login-profile preamble", () => {
// A capturing exec surface. It records each command and returns a fixed result,
// so a test reads the exact command string the helper runs.
function createCapturingExec(result: DaytonaExecResult): DaytonaSandboxExec & {
commands: string[];
} {
const commands: string[] = [];
return {
commands,
async executeCommand(command: string): Promise<DaytonaExecResult> {
commands.push(command);
// `id -u` resolves the login user id. Return a fixed uid so the caller
// continues to the node helper command under test.
if (command.startsWith("id -u")) {
return { exitCode: 0, result: "1000" };
}
return result;
},
};
}
// The command sources /etc/profile before it runs node. The Daytona image may
// expose node only through a login profile, so node must run after the profile
// source. This assertion proves the helper runtime and the login PTY capability
// stay consistent.
function expectProfileBeforeNode(command: string | undefined): void {
expect(command).toBeDefined();
const profileIndex = command!.indexOf("/etc/profile");
const nodeIndex = command!.indexOf("node -e");
expect(profileIndex).toBeGreaterThanOrEqual(0);
expect(nodeIndex).toBeGreaterThanOrEqual(0);
expect(profileIndex).toBeLessThan(nodeIndex);
}
it("sources the login profiles before it runs the node inspect helper", async () => {
const exec = createCapturingExec({ exitCode: 0, result: "ABSENT" });
const fs = createDaytonaLoginHomeFs(exec);
await fs.inspect(HOME);
expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e")));
});
it("sources the login profiles before it runs the node create helper", async () => {
const exec = createCapturingExec({ exitCode: 0, result: "" });
const fs = createDaytonaLoginHomeFs(exec);
await fs.createDirectory(HOME, "700");
expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e")));
});
});
// The real login-home filesystem runs a node helper on the sandbox. The helper
// walks the path with `/proc/self/fd`, which is Linux only. A non-Linux host skips
// these tests. The fake exec runs the helper locally, so the descriptor-relative
// walk is tested against a real filesystem. The login sandbox is Linux.
const describeLinux = process.platform === "linux" ? describe : describe.skip;
describeLinux("createDaytonaLoginHomeFs — descriptor-relative walk", () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), "daytona-login-home-"));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// A local exec surface. It runs the composed command with `/bin/sh -c`, so the
// real node helper runs against the local filesystem.
function createLocalExec(): DaytonaSandboxExec {
return {
async executeCommand(command: string): Promise<DaytonaExecResult> {
const result = spawnSync("/bin/sh", ["-c", command], { encoding: "utf8" });
return { exitCode: result.status ?? undefined, result: result.stdout ?? "" };
},
};
}
const UUID = "11111111-2222-4333-8444-555555555555";
it("reports a not-yet-created target as absent", async () => {
const fs = createDaytonaLoginHomeFs(createLocalExec());
const home = path.join(root, "absent-root", UUID);
const inspection = await fs.inspect(home);
expect(inspection.exists).toBe(false);
});
it("creates the login root and the session home relative to a trusted descriptor", async () => {
const fs = createDaytonaLoginHomeFs(createLocalExec());
const home = path.join(root, "clean-root", UUID);
await fs.createDirectory(home, "700");
const inspection = await fs.inspect(home);
expect(inspection.exists).toBe(true);
expect(inspection.isSymlink).toBe(false);
expect(inspection.isDirectory).toBe(true);
expect(inspection.mode).toBe("700");
expect(inspection.ownerUid).toBe(process.getuid ? process.getuid() : inspection.ownerUid);
});
it("rejects a pre-existing session home target", async () => {
const fs = createDaytonaLoginHomeFs(createLocalExec());
const home = path.join(root, "exists-root", UUID);
await fs.createDirectory(home, "700");
// A second create of the same target fails, because the target exists.
await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
});
it("fails the inspection closed when the login root is a symlink", async () => {
// Pre-place a symlink at the login-root ancestor. The descriptor-relative walk
// opens the root with a no-follow open, so the inspection fails closed.
const fs = createDaytonaLoginHomeFs(createLocalExec());
const attacker = path.join(root, "inspect-attacker");
mkdirSync(attacker, { mode: 0o700 });
const rootLink = path.join(root, "inspect-symlink-root");
symlinkSync(attacker, rootLink);
const home = path.join(rootLink, UUID);
await expect(fs.inspect(home)).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
});
it("fails the creation closed when the login root is a pre-placed symlink", async () => {
// Pre-place a symlink at the login-root ancestor that points at an attacker
// tree. The creation opens the root with a no-follow open after the tolerant
// `mkdir`, so it fails closed and creates no directory in the attacker tree.
const fs = createDaytonaLoginHomeFs(createLocalExec());
const attacker = path.join(root, "create-attacker");
mkdirSync(attacker, { mode: 0o700 });
const rootLink = path.join(root, "create-symlink-root");
symlinkSync(attacker, rootLink);
const home = path.join(rootLink, UUID);
await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
// The creation placed no session home inside the attacker tree.
expect(existsSync(path.join(attacker, UUID))).toBe(false);
});
});
describe("createDaytonaLoginPtySessionOpener", () => {
it("opens a session for the descriptor on each call", async () => {
const process = createFakeProcess();
const opener = createDaytonaLoginPtySessionOpener(process, createFakeHomeFs(), {
cwd: "/workspace",
});
const session = await opener(CLAUDE);
expect(process.createOptions?.cwd).toBe("/workspace");
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
expect(typeof session.onData).toBe("function");
expect(typeof session.write).toBe("function");
expect(typeof session.wait).toBe("function");
expect(typeof session.kill).toBe("function");
expect(typeof session.close).toBe("function");
});
});
@@ -0,0 +1,586 @@
// The Daytona pseudo-terminal (PTY) session for the shared login flow. The login
// command needs a real pseudo-terminal: pipe stdio emits no login prompt. The
// Daytona SDK opens a PTY through `process.createPty`. This module binds that PTY
// to a small session that the login PTY transport consumes, so the login runner
// runs the command on a real terminal, streams the terminal output, and delivers
// the delayed browser code plus the Enter byte.
//
// Command contract: the host resolves a closed login command key and a validated
// session home, and it carries them in a launch descriptor. The descriptor holds
// no command string. This module maps the key to a compile-time command, so a
// caller cannot select or override the command. For the Codex key the module
// composes `exec env CODEX_HOME=<encoded-home> <fixed-codex-command>`. For the
// Claude key it composes `exec <fixed-claude-command>` with no CODEX_HOME. The
// module encodes the dynamic home with a POSIX shell-argument encoder, so the
// home cannot add a second shell token or a second command.
//
// Session home: the module revalidates the descriptor and the home shape, then
// creates the exact UUID directory as a NEW directory with mode 0700, owned by the
// login user. The inspection and the creation open the filesystem root, then walk
// each path component with a no-follow, directory-only open, so a symlink at any
// ancestor (the login root or a directory above it) fails closed. A single
// composite `stat` or `mkdir` follows a symlink at an ancestor; the per-component
// walk does not. The creation creates the login root if the root is absent and the
// UUID directory as a NEW directory, both relative to an open trusted descriptor.
// The module rejects a pre-existing target, a symlink, a non-directory, a wrong
// owner, and a wrong mode. It uses no recursive delete. It re-checks the directory
// with a no-symlink walk before it opens the terminal and again before it sends the
// launch input, so a symlink swapped in after creation fails before the launch.
//
// Dependency boundary: this provider plugin ships standalone (the workspace
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
// workspace package. It declares the small session shape locally through
// {@link LoginPtySession}. The shape matches the `LoginPtySession` interface in
// `@paperclipai/adapter-utils`, so the transport factory `createLoginPtyTransport`
// accepts the session opener from this module with no adapter. The runner drives
// the transport; the transport drives this session.
//
// SDK boundary: the module holds no Daytona SDK type import. It declares the small
// subset of the SDK PTY and filesystem surfaces that it uses through
// {@link DaytonaPtyProcess}, {@link DaytonaPtyHandle}, and {@link DaytonaSandboxExec}.
// The SDK `Process` and `PtyHandle` satisfy those subsets, so a caller passes
// `sandbox.process` with no adapter. The narrow surface keeps the module
// unit-testable with a fake PTY and a fake filesystem.
//
// Security (secret handling): the login runner delivers the browser code and the
// Enter byte through {@link DaytonaPtyHandle.sendInput}. The SDK sends the input
// over the PTY socket, so the code never rides a command line and never reaches a
// process argument list. The session forwards the raw terminal bytes with no ANSI
// or OSC 8 handling; the login parser owns that handling.
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { sendPtyInputInChunks } from "./pty-chunked-input.js";
/**
* The closed set of login command identities. The worker maps each key to a
* compile-time command. A value outside this set fails closed before the module
* touches the filesystem.
*/
export type LoginCommandKey = "claude" | "codex";
/**
* The host-resolved launch descriptor. It carries the closed command key and the
* server-controlled session home. It holds no command string.
*/
export interface LoginPtyLaunchDescriptor {
/** The host-resolved fixed command identity. */
loginCommandKey: LoginCommandKey;
/**
* The server-controlled session home. The shape is exact:
* `/tmp/paperclip-adapter-login/<uuid>`.
*/
sessionHome: string;
}
/**
* The compile-time command for each login command key. The Daytona plugin ships
* standalone, so it holds its own copy of the fixed commands. A future change to
* a command lands here and in the adapter constant together.
*/
const LOGIN_COMMAND_BY_KEY: Readonly<Record<LoginCommandKey, string>> = {
claude: "claude setup-token",
codex: "codex login --device-auth",
};
/** The octal mode string for a new session home directory. */
const LOGIN_HOME_MODE = "700";
/** The fixed root for a login session home. */
const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login";
/**
* The exact absolute path shape for a login session home. The path is the fixed
* root, one slash, and one UUID. The anchors reject a relative path, a traversal,
* whitespace, a control character, and a shell metacharacter, because none of
* those match the UUID or the fixed root.
*/
const SESSION_HOME_PATTERN =
/^\/tmp\/paperclip-adapter-login\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** The fixed non-secret error a rejected launch descriptor or session home returns. */
export const LOGIN_PTY_DESCRIPTOR_REJECTED = "LOGIN_PTY_DESCRIPTOR_REJECTED";
/** The fixed non-secret error a rejected session home directory returns. */
export const LOGIN_PTY_HOME_REJECTED = "LOGIN_PTY_HOME_REJECTED";
/**
* Encodes one value as a single POSIX shell argument. It wraps the value in
* single quotes and escapes each embedded single quote. The shell treats every
* other character inside single quotes as a literal, so an encoded value cannot
* add a second shell token or a second command.
*/
export function encodePosixShellArg(value: string): string {
return `'${value.split("'").join("'\\''")}'`;
}
/** Reports whether a value is a member of the closed login command key set. */
export function isLoginCommandKey(value: unknown): value is LoginCommandKey {
return value === "claude" || value === "codex";
}
/**
* Composes the launch line for the descriptor. For the Codex key it prefixes one
* safely encoded `CODEX_HOME` assignment with `env`. For the Claude key it adds no
* `CODEX_HOME`. It replaces the interactive shell with the command through `exec`,
* so the pseudo-terminal runs the command directly and its exit code becomes the
* PTY exit code.
*/
export function composeLaunchLine(descriptor: LoginPtyLaunchDescriptor): string {
const command = LOGIN_COMMAND_BY_KEY[descriptor.loginCommandKey];
if (descriptor.loginCommandKey === "codex") {
const encodedHome = encodePosixShellArg(descriptor.sessionHome);
return `exec env CODEX_HOME=${encodedHome} ${command}`;
}
return `exec ${command}`;
}
/**
* A live pseudo-terminal session for one login command. The session allocates a
* real pseudo-terminal, streams the raw terminal output, accepts delayed input,
* and stops the child. The shape matches the `LoginPtySession` interface in
* `@paperclipai/adapter-utils`, so the transport factory there accepts a session
* opener that returns this session.
*/
export interface LoginPtySession {
/** Registers the one output listener. The session streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Writes raw input bytes to the pseudo-terminal. */
write(data: string): void;
/** Resolves with the child exit code when the command ends. */
wait(): Promise<{ exitCode: number | null }>;
/** Stops the child process. Safe to call more than one time. */
kill(): void;
/** Releases the session resources. Safe to call more than one time. */
close(): Promise<void>;
}
/** Opens a {@link LoginPtySession} for `descriptor`. The transport calls it one time. */
export type LoginPtySessionOpener = (
descriptor: LoginPtyLaunchDescriptor,
) => Promise<LoginPtySession>;
/**
* The subset of the Daytona SDK `PtyHandle` that the session uses. The SDK
* `PtyHandle` satisfies this interface, so a caller passes the real handle with
* no adapter.
*/
export interface DaytonaPtyHandle {
/** Resolves when the PTY socket connection is ready for input and output. */
waitForConnection(): Promise<void>;
/** Sends raw input bytes to the pseudo-terminal. */
sendInput(data: string | Uint8Array): Promise<void>;
/** Resolves with the exit result when the pseudo-terminal process ends. */
wait(): Promise<{ exitCode?: number; error?: string }>;
/** Kills the pseudo-terminal process. */
kill(): Promise<void>;
/** Closes the PTY socket and releases the resources. */
disconnect(): Promise<void>;
}
/**
* The options the session passes to `createPty`. The session sets a fixed
* terminal size and one output callback. It sets the working directory when the
* caller provides one.
*/
export interface DaytonaPtyCreateOptions {
id: string;
cwd?: string;
cols: number;
rows: number;
onData: (data: Uint8Array) => void | Promise<void>;
}
/**
* The subset of the Daytona SDK `Process` that the session uses. The SDK
* `Process` satisfies this interface, so a caller passes `sandbox.process`.
*/
export interface DaytonaPtyProcess {
createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle>;
}
/** The result of one command run on the sandbox. */
export interface DaytonaExecResult {
exitCode?: number;
result?: string;
}
/**
* The subset of the Daytona SDK `Process` that the filesystem helper uses. The
* SDK `Process` satisfies this interface through `executeCommand`, so a caller
* passes `sandbox.process`.
*/
export interface DaytonaSandboxExec {
executeCommand(
command: string,
cwd?: string,
env?: Record<string, string>,
timeout?: number,
): Promise<DaytonaExecResult>;
}
/** One no-follow inspection of a filesystem path. */
export interface LoginHomeInspection {
/** True when the path exists (as any type). */
exists: boolean;
/** True when the path itself is a symbolic link. */
isSymlink: boolean;
/** True when the path itself is a directory. */
isDirectory: boolean;
/** The octal permission string, for example `700`. Empty when the path is absent. */
mode: string;
/** The owner user id, or null when the path is absent. */
ownerUid: number | null;
}
/**
* The narrow filesystem surface the session home operations need. The production
* surface runs commands on the sandbox; a unit test injects a fake. The inspection
* and the creation walk each path component with a no-follow open, so a symlink at
* any ancestor fails closed and a symlink at the target reports the link.
*/
export interface DaytonaLoginHomeFs {
/** Resolves the user id of the login user that runs the pseudo-terminal. */
loginUserId(): Promise<number>;
/**
* Inspects `path` without following a symlink at the target or at any ancestor.
* It rejects (throws) when an ancestor is a symlink or a non-directory.
*/
inspect(path: string): Promise<LoginHomeInspection>;
/**
* Creates `path` as a NEW directory with the octal `mode`. It creates the login
* root ancestor if the root is absent. It fails when the target path exists or
* when an ancestor is a symlink. It follows no composite pathname.
*/
createDirectory(path: string, mode: string): Promise<void>;
}
/** The options for the Daytona login PTY session. */
export interface DaytonaLoginPtyOptions {
/** The working directory for the login PTY. Defaults to the sandbox default. */
cwd?: string;
}
// The terminal size for the login PTY. The login UI prints a short prompt, so a
// standard terminal size is enough. A fixed size keeps the output stable.
const LOGIN_PTY_COLS = 120;
const LOGIN_PTY_ROWS = 30;
/**
* The input terminator that replaces the interactive shell with the login
* command. The `exec` builtin replaces the shell process image with the command,
* so the PTY runs the command directly. When the command ends, the PTY ends with
* the command exit code, and the delayed input reaches the command, not a shell.
* The terminal reads the carriage return as the Enter key.
*/
const PTY_COMMAND_TERMINATOR = "\r";
/** Normalizes an octal permission string to its numeric value for comparison. */
function octalMode(value: string): number {
return Number.parseInt(value, 8);
}
/**
* Revalidates the launch descriptor. It fails closed when the command key is
* outside the closed set or the session home shape is wrong. A unit test that
* bypasses the host boundary hits this check, so the provider never trusts an
* unvalidated descriptor.
*/
function assertDescriptor(descriptor: LoginPtyLaunchDescriptor): void {
if (!isLoginCommandKey(descriptor.loginCommandKey)) {
throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED);
}
if (!SESSION_HOME_PATTERN.test(descriptor.sessionHome)) {
throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED);
}
}
/**
* Creates and validates the exact session home directory. It rejects a
* pre-existing target (including a symlink), creates the directory as a NEW
* directory with mode 0700, then verifies the directory type, the no-symlink
* state, the mode, and the login-user ownership. It uses no recursive delete.
*/
async function prepareSessionHome(fs: DaytonaLoginHomeFs, sessionHome: string): Promise<void> {
const loginUid = await fs.loginUserId();
// Reject a pre-existing target. A no-follow inspection reports a symlink, a
// file, or a directory as present, so any pre-existing target fails here.
const before = await fs.inspect(sessionHome);
if (before.exists) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
// Create the exact directory as a NEW directory with mode 0700. The create
// fails when the path exists, so the create never follows a symlink.
await fs.createDirectory(sessionHome, LOGIN_HOME_MODE);
// Verify the created directory. Reject a symlink, a non-directory, a wrong
// owner, and a wrong mode. Do not delete on a rejection; the sandbox is
// ephemeral and the provider uses no recursive delete.
const after = await fs.inspect(sessionHome);
if (
!after.exists ||
after.isSymlink ||
!after.isDirectory ||
after.ownerUid !== loginUid ||
octalMode(after.mode) !== octalMode(LOGIN_HOME_MODE)
) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
}
/**
* Re-checks the directory with a no-symlink check. It rejects a symlink and a
* non-directory, so a symlink swapped in after creation fails before the launch.
*/
async function assertHomeStillSafe(fs: DaytonaLoginHomeFs, sessionHome: string): Promise<void> {
const now = await fs.inspect(sessionHome);
if (!now.exists || now.isSymlink || !now.isDirectory) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
}
/**
* Opens a Daytona PTY session for `descriptor` and returns it as a
* {@link LoginPtySession}. The function revalidates the descriptor and the home
* shape, creates and validates the session home, opens a real pseudo-terminal,
* re-checks the directory before the launch, composes the safe launch line, and
* sends it. The session streams the raw terminal output, delivers delayed input,
* and stops the child.
*
* The function decodes the terminal bytes as a UTF-8 stream, so a multibyte
* character that splits across two output chunks stays whole. It buffers the
* output until the transport registers the listener, so no early chunk is lost.
*/
export async function openDaytonaLoginPtySession(
process: DaytonaPtyProcess,
fs: DaytonaLoginHomeFs,
descriptor: LoginPtyLaunchDescriptor,
options?: DaytonaLoginPtyOptions,
): Promise<LoginPtySession> {
assertDescriptor(descriptor);
// Create and validate the session home before the terminal opens.
await prepareSessionHome(fs, descriptor.sessionHome);
// Re-check the directory with a no-symlink check before `createPty`.
await assertHomeStillSafe(fs, descriptor.sessionHome);
const decoder = new TextDecoder("utf-8");
let listener: ((chunk: string) => void) | null = null;
let buffered = "";
const handle = await process.createPty({
id: `paperclip-login-pty-${randomUUID()}`,
...(options?.cwd ? { cwd: options.cwd } : {}),
cols: LOGIN_PTY_COLS,
rows: LOGIN_PTY_ROWS,
onData: (data: Uint8Array): void => {
// Decode the terminal bytes as a stream, so a split multibyte character
// stays whole across two chunks. Forward the raw text; the parser owns the
// ANSI and OSC 8 handling.
const text = decoder.decode(data, { stream: true });
if (text.length === 0) return;
if (listener) listener(text);
else buffered += text;
},
});
await handle.waitForConnection();
// Re-check the directory with a no-symlink check immediately before the launch
// input, so a symlink swapped in after creation fails before `sendInput`.
await assertHomeStillSafe(fs, descriptor.sessionHome);
// Replace the interactive shell with the login command, so the pseudo-terminal
// runs the command directly. The runner then writes the delayed browser code
// to the command, not to a shell.
await handle.sendInput(composeLaunchLine(descriptor) + PTY_COMMAND_TERMINATOR);
// The tail of the write chain. The chunker awaits each chunk, so one write can
// suspend between its chunks. The chain runs each write after the previous
// write ends, so the chunks of two writes never interleave on the wire.
let writeChain: Promise<void> = Promise.resolve();
return {
onData(next: (chunk: string) => void): void {
listener = next;
if (buffered.length > 0) {
const pending = buffered;
buffered = "";
next(pending);
}
},
write(data: string): void {
// Send the input as byte-bounded chunks under the provider message cap. The
// login input is short today, so the latent defect never fires, but the same
// unbounded write goes through the one chunker. The chain runs this write
// after the previous write ends, so two writes keep their order and never
// interleave their chunks. A write error must not throw into the runner, so
// the runner's fixed status stays the single result path.
writeChain = writeChain.then(() =>
sendPtyInputInChunks((chunk) => handle.sendInput(chunk), data),
);
},
async wait(): Promise<{ exitCode: number | null }> {
const result = await handle.wait();
return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null };
},
kill(): void {
void handle.kill().catch(() => undefined);
},
async close(): Promise<void> {
await handle.disconnect().catch(() => undefined);
},
};
}
/**
* The inspection helper source. The provider reads it from its own script file and
* runs it on the sandbox as `<login-profile-preamble> && node -e <script> <path>`.
* The preamble sources the login profiles first, so `node` resolves on an image
* that exposes node only through a login profile. The sandbox already runs node
* for the Paperclip bridge, so the helper needs no extra runtime. The helper
* source lives in `scripts/login-home-inspect.cjs`, so no large script stays as a
* string literal in this module. The helper opens the filesystem root, then walks
* each ancestor of the final path component with a no-follow, directory-only open,
* and reads the final component with `lstat`.
*
* The helper prints one line and sets one exit code:
* - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
* - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
* where `<type>` is `symlink`, `directory`, or `other`;
* - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
* the caller fails closed on an ancestor symlink.
*/
const LOGIN_HOME_INSPECT_SCRIPT = readFileSync(
fileURLToPath(new URL("./scripts/login-home-inspect.cjs", import.meta.url)),
"utf8",
);
/**
* The creation helper source. The provider reads it from its own script file and
* runs it on the sandbox as
* `<login-profile-preamble> && node -e <script> <path> <octal-mode>`. The preamble
* sources the login profiles first, so `node` resolves on an image that exposes
* node only through a login profile. The helper source lives in
* `scripts/login-home-create.cjs`. The helper opens the filesystem
* root, then walks each ancestor above the login root with a no-follow,
* directory-only open. It creates the login root (the second-to-last component) if
* the root is absent, then opens the root with a no-follow open, so a symlink at
* the root fails closed. It creates the final component as a NEW directory relative
* to the root descriptor, so a pre-existing target fails and the create never
* follows a symlink. It exits 0 on success and non-zero on every failure.
*
* The no-follow open of the root after the create closes the swap window: an
* attacker that replaces the root with a symlink between the create and the open
* fails the open.
*/
const LOGIN_HOME_CREATE_SCRIPT = readFileSync(
fileURLToPath(new URL("./scripts/login-home-create.cjs", import.meta.url)),
"utf8",
);
/**
* The login-profile preamble for a node helper command. Daytona's
* `executeCommand` runs the command in a non-login shell, so the shell does not
* source `/etc/profile` on its own. The Daytona reference image puts `node` on
* the PATH through `/etc/profile.d`, which only a login profile sources. The
* login pseudo-terminal resolves the login command through the same profiles, so
* the helper sources the login profiles first and a non-login shell then resolves
* `node`. This keeps the helper runtime and the login PTY capability consistent:
* an image that exposes `node` only through a login profile runs the helper. The
* main exec path uses the same profile chain (see `buildLoginShellScript` in
* `plugin.ts`). Each source line fails open (`|| true`), so a missing profile
* does not fail the helper.
*/
const LOGIN_PROFILE_PREAMBLE = [
"if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi",
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
].join(" && ");
/**
* Composes a `node` helper command that runs after the login-profile preamble.
* The preamble puts `node` on the PATH, then the `&&` chain runs `node -e` with
* the already shell-encoded argument list. The `node` exit code becomes the
* command exit code, and `2>/dev/null` suppresses only the node stderr. The
* caller passes an argument string that is already shell-encoded.
*/
function composeNodeHelperCommand(encodedArgs: string): string {
return `${LOGIN_PROFILE_PREAMBLE} && node -e ${encodedArgs} 2>/dev/null`;
}
/**
* Creates a {@link DaytonaLoginHomeFs} bound to a Daytona `process`. It runs a
* fixed node helper on the sandbox to inspect and create the session home. Each
* helper opens the filesystem root, then walks each path component with a
* no-follow, directory-only open, so a symlink at any ancestor fails closed. The
* inspection reads the final component with `lstat`, so a symlink at the target
* reports the link. The creation creates the login root if absent and the session
* home as a NEW directory, both relative to an open trusted descriptor, so no
* operation follows a composite pathname. The real filesystem operations land
* against a live sandbox; a unit test injects a fake surface instead.
*/
export function createDaytonaLoginHomeFs(exec: DaytonaSandboxExec): DaytonaLoginHomeFs {
return {
async loginUserId(): Promise<number> {
const out = await exec.executeCommand("id -u");
const uid = Number.parseInt((out.result ?? "").trim(), 10);
if (!Number.isInteger(uid)) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
return uid;
},
async inspect(path: string): Promise<LoginHomeInspection> {
// Read the file type, the octal mode, and the owner uid with a descriptor
// relative no-follow walk. A non-zero exit means a symlink or a non-directory
// at an ancestor, so the read fails closed. An `ABSENT` line means the target
// does not exist yet, which is the safe precondition for a create.
const script = encodePosixShellArg(LOGIN_HOME_INSPECT_SCRIPT);
const encodedPath = encodePosixShellArg(path);
const out = await exec.executeCommand(composeNodeHelperCommand(`${script} ${encodedPath}`));
if ((out.exitCode ?? 0) !== 0) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
const text = (out.result ?? "").trim();
if (text.length === 0 || text === "ABSENT") {
return { exists: false, isSymlink: false, isDirectory: false, mode: "", ownerUid: null };
}
const [fileType = "", mode = "", ownerText = ""] = text.split("|");
const ownerUid = Number.parseInt(ownerText, 10);
return {
exists: true,
isSymlink: fileType === "symlink",
isDirectory: fileType === "directory",
mode,
ownerUid: Number.isInteger(ownerUid) ? ownerUid : null,
};
},
async createDirectory(path: string, mode: string): Promise<void> {
const script = encodePosixShellArg(LOGIN_HOME_CREATE_SCRIPT);
const encodedPath = encodePosixShellArg(path);
const encodedMode = encodePosixShellArg(mode);
const out = await exec.executeCommand(
composeNodeHelperCommand(`${script} ${encodedPath} ${encodedMode}`),
);
if ((out.exitCode ?? 0) !== 0) {
throw new Error(LOGIN_PTY_HOME_REJECTED);
}
},
};
}
/**
* Creates a {@link LoginPtySessionOpener} bound to a Daytona `process` and a
* {@link DaytonaLoginHomeFs}. The opener runs the login command on a real
* pseudo-terminal, streams the terminal output, delivers the delayed browser code
* plus the Enter byte, and stops the child for a terminal state.
*/
export function createDaytonaLoginPtySessionOpener(
process: DaytonaPtyProcess,
fs: DaytonaLoginHomeFs,
options?: DaytonaLoginPtyOptions,
): LoginPtySessionOpener {
return (descriptor: LoginPtyLaunchDescriptor) =>
openDaytonaLoginPtySession(process, fs, descriptor, options);
}
@@ -67,7 +67,11 @@ const manifest: PaperclipPluginManifestV1 = {
supportsTemplateDelete: true,
// Daytona hosts an interactive login on a real pseudo-terminal. It is the
// only bundled provider that implements the login pseudo-terminal methods,
// so it advertises the capability.
// so it advertises the capability. The session home helpers and the
// credential reader run on node, and the sandbox already runs node for the
// Paperclip bridge. So the login has no extra runtime prerequisite, and the
// advertised capability matches the runtime contract for every configured
// image or snapshot.
supportsLoginPty: true,
configSchema: {
type: "object",
@@ -46,25 +46,32 @@ import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js"
// The session runs the login command on a real pseudo-terminal, streams the
// terminal output, and delivers the delayed browser code plus the Enter byte. A
// later phase binds the opener to `sandbox.process` and wraps it with the
// `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` to
// `createLoginPtyTransport` factory from `@paperclipai/adapter-utils` to
// build the transport the login runner drives.
export {
createDaytonaSetupTokenPtySessionOpener,
openDaytonaSetupTokenPtySession,
} from "./setup-token-pty.js";
createDaytonaLoginPtySessionOpener,
openDaytonaLoginPtySession,
createDaytonaLoginHomeFs,
} from "./login-pty.js";
export type {
SetupTokenPtySession,
SetupTokenPtySessionOpener,
LoginPtySession,
LoginPtySessionOpener,
LoginPtyLaunchDescriptor,
DaytonaPtyHandle,
DaytonaPtyProcess,
DaytonaPtyCreateOptions,
DaytonaSetupTokenPtyOptions,
} from "./setup-token-pty.js";
import { openDaytonaSetupTokenPtySession as openSetupTokenPtySession } from "./setup-token-pty.js";
DaytonaLoginPtyOptions,
DaytonaLoginHomeFs,
} from "./login-pty.js";
import {
openDaytonaLoginPtySession as openLoginPtySession,
createDaytonaLoginHomeFs,
} from "./login-pty.js";
import type {
SetupTokenPtySession as SetupTokenPtyWorkerSession,
LoginPtySession as LoginPtyWorkerSession,
DaytonaPtyProcess,
} from "./setup-token-pty.js";
DaytonaSandboxExec,
} from "./login-pty.js";
// The Daytona duplex command stream for the sandbox callback bridge. The channel
// runs the gateway command on a raw pseudo-terminal, streams the frames, and
@@ -1904,17 +1911,17 @@ async function executeInSession(
// create time, so the host closes the exact terminal by that identifier even
// when the open reply was lost. It also indexes by the worker session identifier
// for input and stop. The `onShutdown` hook closes every open session here.
interface DaytonaSetupTokenPtyEntry {
interface DaytonaLoginPtyEntry {
hostRouteId: string;
workerSessionId: string;
session: SetupTokenPtyWorkerSession;
session: LoginPtyWorkerSession;
}
const daytonaSetupTokenPtyByRoute = new Map<string, DaytonaSetupTokenPtyEntry>();
const daytonaSetupTokenPtyBySession = new Map<string, DaytonaSetupTokenPtyEntry>();
const daytonaLoginPtyByRoute = new Map<string, DaytonaLoginPtyEntry>();
const daytonaLoginPtyBySession = new Map<string, DaytonaLoginPtyEntry>();
function forgetDaytonaSetupTokenPty(entry: DaytonaSetupTokenPtyEntry): void {
daytonaSetupTokenPtyByRoute.delete(entry.hostRouteId);
daytonaSetupTokenPtyBySession.delete(entry.workerSessionId);
function forgetDaytonaLoginPty(entry: DaytonaLoginPtyEntry): void {
daytonaLoginPtyByRoute.delete(entry.hostRouteId);
daytonaLoginPtyBySession.delete(entry.workerSessionId);
}
// The worker-side registry of live duplex channels. The worker registers each
@@ -2731,55 +2738,60 @@ const plugin = definePlugin({
});
},
// Open one live Claude `setup-token` login pseudo-terminal. Resolve
// the cached sandbox by the provider lease id, run the fixed login command on a
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
// provider lease id, revalidate the host launch descriptor and the session
// home, create and validate the session home, run the fixed login command on a
// real pseudo-terminal, and register the session under the host route id. Stream
// the raw output and the exit through `ctx.setupTokenPty`, bound to the returned
// the raw output and the exit through `ctx.loginPty`, bound to the returned
// worker session id. Fail closed when no cached sandbox matches the lease.
async onSetupTokenPtyOpen(params) {
async onLoginPtyOpen(params) {
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
if (!sandbox) {
throw new Error(
"Daytona setup-token login: no cached sandbox resolves the provider lease.",
"Daytona login pseudo-terminal: no cached sandbox resolves the provider lease.",
);
}
const session = await openSetupTokenPtySession(
const homeFs = createDaytonaLoginHomeFs(
sandbox.process as unknown as DaytonaSandboxExec,
);
const session = await openLoginPtySession(
sandbox.process as unknown as DaytonaPtyProcess,
params.command,
homeFs,
{ loginCommandKey: params.loginCommandKey, sessionHome: params.sessionHome },
);
const workerSessionId = `pty-${randomUUID()}`;
const entry: DaytonaSetupTokenPtyEntry = {
const entry: DaytonaLoginPtyEntry = {
hostRouteId: params.hostRouteId,
workerSessionId,
session,
};
daytonaSetupTokenPtyByRoute.set(params.hostRouteId, entry);
daytonaSetupTokenPtyBySession.set(workerSessionId, entry);
daytonaLoginPtyByRoute.set(params.hostRouteId, entry);
daytonaLoginPtyBySession.set(workerSessionId, entry);
// Register the output listener before the first input, so no early output
// chunk is lost. The client stamps the worker session id, so the host binds
// the output to the open route.
session.onData((chunk) => {
pluginContext?.setupTokenPty.output(workerSessionId, chunk);
pluginContext?.loginPty.output(workerSessionId, chunk);
});
// Forward the child exit one time. The host resolves the login run on it.
void session.wait().then(
(result) => pluginContext?.setupTokenPty.exit(workerSessionId, result.exitCode),
() => pluginContext?.setupTokenPty.exit(workerSessionId, null),
(result) => pluginContext?.loginPty.exit(workerSessionId, result.exitCode),
() => pluginContext?.loginPty.exit(workerSessionId, null),
);
return { workerSessionId };
},
// Write delayed input to an open login pseudo-terminal, keyed by the worker
// session id. Drop the input for an unknown session.
async onSetupTokenPtyInput(params) {
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
async onLoginPtyInput(params) {
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
if (!entry) return;
entry.session.write(params.data);
},
// Stop an open login pseudo-terminal child, keyed by the worker session id.
async onSetupTokenPtyStop(params) {
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
async onLoginPtyStop(params) {
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
if (!entry) return;
entry.session.kill();
},
@@ -2788,10 +2800,10 @@ const plugin = definePlugin({
// close with the same identifier. The close is idempotent: it returns the
// acknowledgement even when the entry is already gone, so the host confirms the
// terminal is closed. The worker never keys the close on the worker session id.
async onSetupTokenPtyClose(params) {
const entry = daytonaSetupTokenPtyByRoute.get(params.hostRouteId);
async onLoginPtyClose(params) {
const entry = daytonaLoginPtyByRoute.get(params.hostRouteId);
if (entry) {
forgetDaytonaSetupTokenPty(entry);
forgetDaytonaLoginPty(entry);
await entry.session.close().catch(() => undefined);
}
return { hostRouteId: params.hostRouteId };
@@ -2828,9 +2840,19 @@ const plugin = definePlugin({
session.onData((chunk) => {
pluginContext?.duplexChannel.data(entry.hostRouteId, workerSessionId, chunk);
});
// Forward the child exit one time. The host resolves the open route on it.
// Forward the child exit one time. The host resolves the open route on it. A
// numeric exit code is a real process exit; a resolved transport close carries
// `transportClosed`, so the host keeps the two apart in the loss taxonomy. A
// rejected wait is not a resolved transport close, so it reports no code and no
// transport-close mark.
void session.wait().then(
(result) => pluginContext?.duplexChannel.exit(entry.hostRouteId, workerSessionId, result.exitCode),
(result) =>
pluginContext?.duplexChannel.exit(
entry.hostRouteId,
workerSessionId,
result.exitCode,
result.transportClosed,
),
() => pluginContext?.duplexChannel.exit(entry.hostRouteId, workerSessionId, null),
);
// Echo the host route id on the reply, so the host binds the exact pair.
@@ -2874,9 +2896,9 @@ const plugin = definePlugin({
// orderly shutdown, then drain the sandbox handle cache, so a graceful shutdown
// holds no live terminal and no live channel.
async onShutdown() {
const openSessions = [...daytonaSetupTokenPtyByRoute.values()];
daytonaSetupTokenPtyByRoute.clear();
daytonaSetupTokenPtyBySession.clear();
const openSessions = [...daytonaLoginPtyByRoute.values()];
daytonaLoginPtyByRoute.clear();
daytonaLoginPtyBySession.clear();
for (const entry of openSessions) {
await entry.session.close().catch(() => undefined);
}
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
PTY_INPUT_CHUNK_BYTES,
PTY_MESSAGE_CAP_BYTES,
sendPtyInputInChunks,
} from "./pty-chunked-input.js";
/**
* A fake `sendInput`. It records each raw byte chunk, so a test asserts the chunk
* count, the chunk sizes, and the rejoined bytes. It copies each chunk, because
* the chunker passes a `subarray` view of one shared byte array.
*/
function createByteRecorder(): {
sendInput: (chunk: Uint8Array) => Promise<void>;
chunks: Uint8Array[];
} {
const chunks: Uint8Array[] = [];
return {
chunks,
async sendInput(chunk: Uint8Array): Promise<void> {
chunks.push(Uint8Array.from(chunk));
},
};
}
/** Concatenate the recorded chunks into one byte array. */
function concatChunks(chunks: readonly Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const joined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.length;
}
return joined;
}
describe("sendPtyInputInChunks", () => {
it("sends a payload below the cap as exactly one send", async () => {
const recorder = createByteRecorder();
await sendPtyInputInChunks(recorder.sendInput, "a small frame\n");
expect(recorder.chunks).toHaveLength(1);
expect(recorder.chunks[0]?.length).toBeLessThanOrEqual(PTY_MESSAGE_CAP_BYTES);
});
it("splits a payload above the cap into more than one send, each at or below the cap", async () => {
const recorder = createByteRecorder();
// A fixed payload well above the provider message cap. It stays fixed, so a
// raised chunk size above this size makes the payload one send and fails this
// test.
const payload = new Uint8Array(150_416).fill(65);
await sendPtyInputInChunks(recorder.sendInput, payload);
expect(recorder.chunks.length).toBeGreaterThan(1);
for (const chunk of recorder.chunks) {
expect(chunk.length).toBeLessThanOrEqual(PTY_INPUT_CHUNK_BYTES);
expect(chunk.length).toBeLessThanOrEqual(PTY_MESSAGE_CAP_BYTES);
}
expect(concatChunks(recorder.chunks)).toEqual(payload);
});
it("slices a multi-byte character across a boundary by bytes and rejoins it", async () => {
const recorder = createByteRecorder();
// A fixed payload: one 1-byte "x", then the 3-byte character "€" repeated. It
// stays fixed, so a raised chunk size above this size makes the payload one
// send and fails this test. The chunk size is a multiple of 3, so a pure "€"
// run would put every boundary on a character edge. The 1-byte prefix shifts
// the "€" run by one byte, so the first chunk boundary lands inside one "€"
// sequence.
const text = `x${"€".repeat(30_000)}`;
const inputBytes = new TextEncoder().encode(text);
expect(inputBytes.length).toBe(90_001);
await sendPtyInputInChunks(recorder.sendInput, text);
expect(recorder.chunks.length).toBeGreaterThan(1);
// The first chunk holds exactly the byte cap and ends inside a "€" sequence,
// because the 1-byte prefix shifts the 3-byte run off the cap boundary. A
// character-index slice could never end mid-sequence, so the slice is by bytes.
expect(recorder.chunks[0]?.length).toBe(PTY_INPUT_CHUNK_BYTES);
// The rejoined bytes equal the input bytes, so a split multi-byte sequence is
// whole again. A streaming decoder on the read side then decodes it.
const joined = concatChunks(recorder.chunks);
expect(joined).toEqual(inputBytes);
expect(new TextDecoder("utf-8").decode(joined)).toBe(text);
});
it("resolves each send before it starts the next send", async () => {
let active = 0;
let maxActive = 0;
const sendCount = { value: 0 };
// A `sendInput` that stays active across a microtask. The awaited loop starts
// the next send only after the previous send resolves, so at most one send is
// active at a time. A synchronous burst would start every send at once.
const sendInput = async (): Promise<void> => {
active += 1;
maxActive = Math.max(maxActive, active);
await Promise.resolve();
sendCount.value += 1;
active -= 1;
};
// Three chunks of the byte cap plus a short tail.
const payload = new Uint8Array(PTY_INPUT_CHUNK_BYTES * 3 + 10).fill(65);
await sendPtyInputInChunks(sendInput, payload);
expect(sendCount.value).toBe(4);
expect(maxActive).toBe(1);
});
it("stops sending later chunks after a chunk rejects", async () => {
const chunks: Uint8Array[] = [];
let errorCount = 0;
// A `sendInput` that records the chunk, then rejects on a later microtask. The
// rejection is asynchronous, so a synchronous burst could not stop the later
// sends. The awaited loop learns the rejection before the next send, so it
// sends one chunk only.
const sendInput = async (chunk: Uint8Array): Promise<void> => {
chunks.push(chunk);
await Promise.resolve();
throw new Error("RAW-PROVIDER-BROKEN-PIPE");
};
// A payload above the cap, so the chunker would fire more than one chunk with
// no fail-fast. The loop stops after the first rejected chunk.
await sendPtyInputInChunks(sendInput, new Uint8Array(150_416).fill(65), () => {
errorCount += 1;
});
// The loop sent one chunk, learned the rejection, and started no later send.
expect(chunks).toHaveLength(1);
// The error handler ran one time for the whole payload.
expect(errorCount).toBe(1);
});
});
@@ -0,0 +1,80 @@
// The shared host-to-sandbox write chunker for the Daytona pseudo-terminal (PTY)
// sessions. The provider PTY WebSocket rejects any single message over 65536
// bytes with WebSocket close code 1009 and closes the connection. `sendInput`
// sends a whole payload in one message, so a payload above the cap takes the
// channel down. Real duplex frames cross the cap (single frames of 127582 and
// 150416 bytes are observed). This module slices each payload into byte-bounded
// chunks under the cap and sends each chunk in order.
/**
* The provider PTY WebSocket message cap, in bytes. The provider rejects a single
* PTY message over this size with WebSocket close code 1009 and closes the
* connection.
*/
export const PTY_MESSAGE_CAP_BYTES = 65536;
/**
* The maximum byte length of one host-to-sandbox chunk. It holds about 5 KiB of
* headroom under {@link PTY_MESSAGE_CAP_BYTES}, so a chunk never sits on the cap
* boundary. A later change in how the provider counts message overhead does not
* push one chunk over the cap and back into the close-code-1009 failure. The
* headroom costs about nine percent more messages for a large payload.
*
* The chunker slices bytes, not string characters, so a chunk boundary can split
* a multi-byte UTF-8 sequence. The read-side streaming `TextDecoder` rejoins a
* split sequence, so a byte slice is safe. A string-index slice could split a
* surrogate pair, so the chunker never slices by character.
*/
export const PTY_INPUT_CHUNK_BYTES = 60 * 1024;
/** The single UTF-8 encoder the chunker reuses for every string payload. */
const PTY_INPUT_TEXT_ENCODER = new TextEncoder();
/**
* Send one host-to-sandbox payload as byte-bounded chunks under the provider
* message cap. The function encodes a string payload to UTF-8 bytes, slices the
* byte array into chunks of at most {@link PTY_INPUT_CHUNK_BYTES}, and calls
* `sendInput` for each chunk in order.
*
* The loop awaits each chunk before it sends the next chunk. The `await` keeps
* the wire order equal to the slice order, and it makes the chunk send fail-fast:
* after a chunk rejects, the function sends no further chunk of this payload. A
* synchronous loop could not fail-fast, because `sendInput` reaches `ws.send`
* before its first `await`, so a synchronous loop puts every chunk on the wire
* before the first rejection settles. The awaited loop learns the rejection
* before it sends the next chunk, so it stops.
*
* The function does not overlap two calls for the same channel. The caller
* serializes its writes (for example, through a promise chain), so the awaited
* loop of one payload never interleaves its chunks with the chunks of the next
* payload.
*
* `onError` runs at most one time. The function reports the first rejected chunk
* and returns. A rejected send never throws out of this function, so the caller's
* stream stays the single result path.
*
* @param sendInput - Sends one raw byte chunk to the pseudo-terminal.
* @param payload - The host-to-sandbox payload, as a string or raw bytes.
* @param onError - Optional. The function calls it one time on the first rejected chunk.
*/
export async function sendPtyInputInChunks(
sendInput: (chunk: Uint8Array) => Promise<void>,
payload: string | Uint8Array,
onError?: () => void,
): Promise<void> {
const bytes = typeof payload === "string" ? PTY_INPUT_TEXT_ENCODER.encode(payload) : payload;
for (let offset = 0; offset < bytes.length; offset += PTY_INPUT_CHUNK_BYTES) {
const chunk = bytes.subarray(offset, offset + PTY_INPUT_CHUNK_BYTES);
try {
// Await the send before the next chunk. The await keeps the wire order and
// lets the loop learn a rejection before it starts a later send.
await sendInput(chunk);
} catch {
// Report the first rejected chunk one time and stop. The function starts no
// later send for this payload, so the wire keeps the ordered, fail-fast
// behavior. The raw provider error never leaves this function.
onError?.();
return;
}
}
}
@@ -0,0 +1,69 @@
// The session-home creation helper. The provider runs it in the sandbox as
// `node -e <this file> <path> <octal-mode>`. The sandbox already runs node for the
// Paperclip bridge, so the helper needs no extra runtime.
//
// The helper opens the filesystem root, then walks each ancestor above the login
// root with a no-follow, directory-only open. Node has no `openat`, so the helper
// opens each next component through `/proc/self/fd/<dfd>/<component>`: the kernel
// resolves the magic descriptor link, then applies `O_NOFOLLOW` to the final
// component. It creates the login root (the second-to-last component) if the root
// is absent, then opens the root with a no-follow open, so a symlink at the root
// fails closed. It creates the final component as a NEW directory relative to the
// root descriptor, so a pre-existing target fails and the create never follows a
// symlink. It exits 0 on success and non-zero on every failure.
//
// The no-follow open of the root after the create closes the swap window: an
// attacker that replaces the root with a symlink between the create and the open
// fails the open.
"use strict";
const fs = require("node:fs");
const path = process.argv[1];
const mode = Number.parseInt(process.argv[2], 8);
if (typeof path !== "string" || !path.startsWith("/")) process.exit(1);
if (!Number.isInteger(mode)) process.exit(1);
const parts = path.split("/").filter((component) => component.length > 0);
if (parts.length < 2) process.exit(1);
let dfd;
try {
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
} catch {
process.exit(1);
}
for (const part of parts.slice(0, -2)) {
try {
dfd = fs.openSync(
`/proc/self/fd/${dfd}/${part}`,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
} catch {
process.exit(1);
}
}
const root = parts[parts.length - 2];
try {
fs.mkdirSync(`/proc/self/fd/${dfd}/${root}`, { mode: 0o700 });
} catch (error) {
if (!error || error.code !== "EEXIST") process.exit(1);
}
let rfd;
try {
rfd = fs.openSync(
`/proc/self/fd/${dfd}/${root}`,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
} catch {
process.exit(1);
}
try {
fs.mkdirSync(`/proc/self/fd/${rfd}/${parts[parts.length - 1]}`, { mode });
} catch {
process.exit(1);
}
process.exit(0);
@@ -0,0 +1,66 @@
// The session-home inspection helper. The provider runs it in the sandbox as
// `node -e <this file> <path>`. The sandbox already runs node for the Paperclip
// bridge, so the helper needs no extra runtime.
//
// The helper opens the filesystem root, then walks each ancestor of the final
// path component with a no-follow, directory-only open. Node has no `openat`, so
// the helper opens each next component through `/proc/self/fd/<dfd>/<component>`:
// the kernel resolves the magic descriptor link, then applies `O_NOFOLLOW` to the
// final component. This binds each open to the descriptor inode, so a symlink at
// any ancestor fails closed. The helper reads the final component with `lstat`, so
// a symlink at the target reports the link, not the target.
//
// The helper prints one line and sets one exit code:
// - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
// - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
// where `<type>` is `symlink`, `directory`, or `other`;
// - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
// the caller fails closed on an ancestor symlink.
"use strict";
const fs = require("node:fs");
const path = process.argv[1];
if (typeof path !== "string" || !path.startsWith("/")) process.exit(3);
const parts = path.split("/").filter((component) => component.length > 0);
if (parts.length === 0) process.exit(3);
function absent() {
process.stdout.write("ABSENT");
process.exit(0);
}
let dfd;
try {
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
} catch {
process.exit(3);
}
for (const part of parts.slice(0, -1)) {
try {
dfd = fs.openSync(
`/proc/self/fd/${dfd}/${part}`,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
} catch (error) {
if (error && error.code === "ENOENT") absent();
process.exit(3);
}
}
const last = parts[parts.length - 1];
let st;
try {
st = fs.lstatSync(`/proc/self/fd/${dfd}/${last}`);
} catch (error) {
if (error && error.code === "ENOENT") absent();
process.exit(3);
}
let fileType;
if (st.isSymbolicLink()) fileType = "symlink";
else if (st.isDirectory()) fileType = "directory";
else fileType = "other";
process.stdout.write(`${fileType}|${(st.mode & 0o7777).toString(8)}|${st.uid}`);
process.exit(0);
@@ -1,222 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createDaytonaSetupTokenPtySessionOpener,
openDaytonaSetupTokenPtySession,
type DaytonaPtyCreateOptions,
type DaytonaPtyHandle,
type DaytonaPtyProcess,
} from "./setup-token-pty.js";
// The Enter byte the terminal login UI reads to submit the browser code.
const ENTER = "\r";
/**
* A fake Daytona PTY handle. It records each input write, drives the output
* stream on demand, and records the kill and the disconnect. The tests use it in
* place of the real SDK `PtyHandle`, so the session runs with no sandbox.
*/
function createFakePtyHandle(onData: (data: Uint8Array) => void | Promise<void>): DaytonaPtyHandle & {
inputs: string[];
emitText: (text: string) => void;
emitBytes: (bytes: Uint8Array) => void;
finish: (exitCode: number | undefined) => void;
killed: number;
disconnected: number;
} {
const encoder = new TextEncoder();
const inputs: string[] = [];
let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null;
const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => {
resolveWait = resolve;
});
let killed = 0;
let disconnected = 0;
return {
async waitForConnection(): Promise<void> {},
async sendInput(data: string | Uint8Array): Promise<void> {
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
},
wait(): Promise<{ exitCode?: number; error?: string }> {
return waitPromise;
},
async kill(): Promise<void> {
killed += 1;
},
async disconnect(): Promise<void> {
disconnected += 1;
},
// Test control below. The session never reads these fields.
emitText(text: string): void {
onData(encoder.encode(text));
},
emitBytes(bytes: Uint8Array): void {
onData(bytes);
},
finish(exitCode: number | undefined): void {
resolveWait?.({ exitCode });
},
get inputs(): string[] {
return inputs;
},
get killed(): number {
return killed;
},
get disconnected(): number {
return disconnected;
},
};
}
/**
* A fake Daytona process. It opens one fake PTY handle and records the create
* options, so a test asserts the terminal size and the command start.
*/
function createFakeProcess(): DaytonaPtyProcess & {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
} {
const state: {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
} = { handle: null, createOptions: null };
return {
get handle() {
return state.handle;
},
get createOptions() {
return state.createOptions;
},
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
state.createOptions = options;
const handle = createFakePtyHandle(options.onData);
state.handle = handle;
return handle;
},
};
}
describe("openDaytonaSetupTokenPtySession", () => {
it("starts the login command on a pseudo-terminal with a fixed size", async () => {
const process = createFakeProcess();
await openDaytonaSetupTokenPtySession(process, "claude setup-token");
expect(process.createOptions?.cols).toBe(120);
expect(process.createOptions?.rows).toBe(30);
// The session replaces the shell with the command through `exec`, so the
// pseudo-terminal runs the command directly and its exit code is the PTY
// exit code.
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
});
it("returns incremental terminal output to the listener", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
session.onData((chunk) => received.push(chunk));
process.handle?.emitText("the url below to sign in\n");
process.handle?.emitText("Paste code here if prompted\n");
expect(received).toEqual(["the url below to sign in\n", "Paste code here if prompted\n"]);
});
it("buffers early output until the listener registers", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
// Output arrives before the transport registers the listener.
process.handle?.emitText("early output ");
process.handle?.emitText("more output");
expect(received).toEqual([]);
session.onData((chunk) => received.push(chunk));
// The session flushes the buffered output in order on registration.
expect(received).toEqual(["early output more output"]);
});
it("delivers the browser code plus the Enter byte to the command", async () => {
const process = createFakeProcess();
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
session.write("BROWSERCODE" + ENTER);
// The first input is the command start; the second input is the delayed
// browser code plus the Enter byte.
expect(process.handle?.inputs[1]).toBe("BROWSERCODE" + ENTER);
expect(process.handle?.inputs[1]?.endsWith(ENTER)).toBe(true);
});
it("keeps a multibyte character whole across two output chunks", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
session.onData((chunk) => received.push(chunk));
// The euro sign is three UTF-8 bytes. Split it across two chunks, so the
// stream decoder must join the bytes.
const euro = new TextEncoder().encode("€");
process.handle?.emitBytes(euro.subarray(0, 2));
process.handle?.emitBytes(euro.subarray(2));
expect(received.join("")).toBe("€");
});
it("resolves wait with the command exit code", async () => {
const process = createFakeProcess();
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
process.handle?.finish(9);
await expect(session.wait()).resolves.toEqual({ exitCode: 9 });
});
it("maps an absent exit code to null", async () => {
const process = createFakeProcess();
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
process.handle?.finish(undefined);
await expect(session.wait()).resolves.toEqual({ exitCode: null });
});
it("kills the child and closes the session", async () => {
const process = createFakeProcess();
const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token");
session.kill();
expect(process.handle?.killed).toBe(1);
await session.close();
expect(process.handle?.disconnected).toBe(1);
});
it("passes the working directory to the pseudo-terminal", async () => {
const process = createFakeProcess();
await openDaytonaSetupTokenPtySession(process, "claude setup-token", { cwd: "/workspace" });
expect(process.createOptions?.cwd).toBe("/workspace");
});
});
describe("createDaytonaSetupTokenPtySessionOpener", () => {
it("opens a session for the command on each call", async () => {
const process = createFakeProcess();
const opener = createDaytonaSetupTokenPtySessionOpener(process, { cwd: "/workspace" });
const session = await opener("claude setup-token");
expect(process.createOptions?.cwd).toBe("/workspace");
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
// The opener returns a session the transport can drive.
expect(typeof session.onData).toBe("function");
expect(typeof session.write).toBe("function");
expect(typeof session.wait).toBe("function");
expect(typeof session.kill).toBe("function");
expect(typeof session.close).toBe("function");
});
});
@@ -1,194 +0,0 @@
// The Daytona pseudo-terminal (PTY) session for the Claude `setup-token` login.
// The login command needs a real pseudo-terminal: pipe stdio emits no login
// prompt. The Daytona SDK opens a PTY through `process.createPty`. This module
// binds that PTY to a small session that the setup-token transport consumes, so
// the login runner runs the command on a real terminal, streams the terminal
// output, and delivers the delayed browser code plus the Enter byte.
//
// Dependency boundary: this provider plugin ships standalone (the workspace
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
// workspace package. It declares the small session shape locally through
// {@link SetupTokenPtySession}. The shape matches the `SetupTokenPtySession`
// interface in `@paperclipai/adapter-utils`, so the transport factory
// `createSetupTokenPtyTransport` accepts the session opener from this module with
// no adapter. The runner drives the transport; the transport drives this session.
//
// SDK boundary: the module holds no Daytona SDK type import. It declares the
// small subset of the SDK PTY surface that it uses through
// {@link DaytonaPtyProcess} and {@link DaytonaPtyHandle}. The SDK `Process` and
// `PtyHandle` satisfy that subset, so a caller passes `sandbox.process` with no
// adapter. The narrow surface keeps the module unit-testable with a fake PTY.
//
// Security (secret handling): the login runner delivers the browser code and the
// Enter byte through {@link DaytonaPtyHandle.sendInput}. The SDK sends the input
// over the PTY socket, so the code never rides a command line and never reaches a
// process argument list. The session forwards the raw terminal bytes with no
// ANSI or OSC 8 handling; the setup-token parser owns that handling.
import { randomUUID } from "node:crypto";
/**
* A live pseudo-terminal session for one setup-token login command. The session
* allocates a real pseudo-terminal, streams the raw terminal output, accepts
* delayed input, and stops the child. The shape matches the
* `SetupTokenPtySession` interface in `@paperclipai/adapter-utils`, so the
* transport factory there accepts a session opener that returns this session.
*/
export interface SetupTokenPtySession {
/** Registers the one output listener. The session streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Writes raw input bytes to the pseudo-terminal. */
write(data: string): void;
/** Resolves with the child exit code when the command ends. */
wait(): Promise<{ exitCode: number | null }>;
/** Stops the child process. Safe to call more than one time. */
kill(): void;
/** Releases the session resources. Safe to call more than one time. */
close(): Promise<void>;
}
/** Opens a {@link SetupTokenPtySession} for `command`. The transport calls it one time. */
export type SetupTokenPtySessionOpener = (command: string) => Promise<SetupTokenPtySession>;
/**
* The subset of the Daytona SDK `PtyHandle` that the session uses. The SDK
* `PtyHandle` satisfies this interface, so a caller passes the real handle with
* no adapter.
*/
export interface DaytonaPtyHandle {
/** Resolves when the PTY socket connection is ready for input and output. */
waitForConnection(): Promise<void>;
/** Sends raw input bytes to the pseudo-terminal. */
sendInput(data: string | Uint8Array): Promise<void>;
/** Resolves with the exit result when the pseudo-terminal process ends. */
wait(): Promise<{ exitCode?: number; error?: string }>;
/** Kills the pseudo-terminal process. */
kill(): Promise<void>;
/** Closes the PTY socket and releases the resources. */
disconnect(): Promise<void>;
}
/**
* The options the session passes to `createPty`. The session sets a fixed
* terminal size and one output callback. It sets the working directory when the
* caller provides one.
*/
export interface DaytonaPtyCreateOptions {
id: string;
cwd?: string;
cols: number;
rows: number;
onData: (data: Uint8Array) => void | Promise<void>;
}
/**
* The subset of the Daytona SDK `Process` that the session uses. The SDK
* `Process` satisfies this interface, so a caller passes `sandbox.process`.
*/
export interface DaytonaPtyProcess {
createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle>;
}
/** The options for the Daytona setup-token PTY session. */
export interface DaytonaSetupTokenPtyOptions {
/** The working directory for the login PTY. Defaults to the sandbox default. */
cwd?: string;
}
// The terminal size for the login PTY. The login UI prints a short prompt, so a
// standard terminal size is enough. A fixed size keeps the output stable.
const SETUP_TOKEN_PTY_COLS = 120;
const SETUP_TOKEN_PTY_ROWS = 30;
/**
* The input terminator that replaces the interactive shell with the login
* command. The `exec` builtin replaces the shell process image with the command,
* so the PTY runs the command directly. When the command ends, the PTY ends with
* the command exit code, and the delayed input reaches the command, not a shell.
* The terminal reads the carriage return as the Enter key.
*/
const PTY_COMMAND_TERMINATOR = "\r";
/**
* Opens a Daytona PTY session for `command` and returns it as a
* {@link SetupTokenPtySession}. The session allocates a real pseudo-terminal,
* streams the raw terminal output, delivers delayed input, and stops the child.
*
* The function starts the login command with `exec`, so the pseudo-terminal runs
* the command directly and the command exit code becomes the PTY exit code. The
* function decodes the terminal bytes as a UTF-8 stream, so a multibyte
* character that splits across two output chunks stays whole. It buffers the
* output until the transport registers the listener, so no early chunk is lost.
*/
export async function openDaytonaSetupTokenPtySession(
process: DaytonaPtyProcess,
command: string,
options?: DaytonaSetupTokenPtyOptions,
): Promise<SetupTokenPtySession> {
const decoder = new TextDecoder("utf-8");
let listener: ((chunk: string) => void) | null = null;
let buffered = "";
const handle = await process.createPty({
id: `paperclip-setup-token-${randomUUID()}`,
...(options?.cwd ? { cwd: options.cwd } : {}),
cols: SETUP_TOKEN_PTY_COLS,
rows: SETUP_TOKEN_PTY_ROWS,
onData: (data: Uint8Array): void => {
// Decode the terminal bytes as a stream, so a split multibyte character
// stays whole across two chunks. Forward the raw text; the parser owns the
// ANSI and OSC 8 handling.
const text = decoder.decode(data, { stream: true });
if (text.length === 0) return;
if (listener) listener(text);
else buffered += text;
},
});
await handle.waitForConnection();
// Replace the interactive shell with the login command, so the pseudo-terminal
// runs the command directly. The runner then writes the delayed browser code
// to the command, not to a shell.
await handle.sendInput(`exec ${command}${PTY_COMMAND_TERMINATOR}`);
return {
onData(next: (chunk: string) => void): void {
listener = next;
if (buffered.length > 0) {
const pending = buffered;
buffered = "";
next(pending);
}
},
write(data: string): void {
// Fire the input write. A write error must not throw into the runner, so
// the runner's fixed status stays the single result path.
void handle.sendInput(data).catch(() => undefined);
},
async wait(): Promise<{ exitCode: number | null }> {
const result = await handle.wait();
return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null };
},
kill(): void {
void handle.kill().catch(() => undefined);
},
async close(): Promise<void> {
await handle.disconnect().catch(() => undefined);
},
};
}
/**
* Creates a {@link SetupTokenPtySessionOpener} bound to a Daytona `process`. Pass
* `sandbox.process` from the Daytona SDK. A later phase wraps the opener with the
* `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` and
* hands the transport to the login runner. The opener runs the login command on
* a real pseudo-terminal, streams the terminal output, delivers the delayed
* browser code plus the Enter byte, and stops the child for a terminal state.
*/
export function createDaytonaSetupTokenPtySessionOpener(
process: DaytonaPtyProcess,
options?: DaytonaSetupTokenPtyOptions,
): SetupTokenPtySessionOpener {
return (command: string) => openDaytonaSetupTokenPtySession(process, command, options);
}
+16 -16
View File
@@ -82,12 +82,12 @@ import type {
PluginExternalObjectResolveResult,
RefreshExternalObjectsParams,
RefreshExternalObjectsResult,
PluginSetupTokenPtyOpenParams,
PluginSetupTokenPtyOpenResult,
PluginSetupTokenPtyInputParams,
PluginSetupTokenPtyStopParams,
PluginSetupTokenPtyCloseParams,
PluginSetupTokenPtyCloseResult,
PluginLoginPtyOpenParams,
PluginLoginPtyOpenResult,
PluginLoginPtyInputParams,
PluginLoginPtyStopParams,
PluginLoginPtyCloseParams,
PluginLoginPtyCloseResult,
PluginDuplexChannelOpenParams,
PluginDuplexChannelOpenResult,
PluginDuplexChannelWriteParams,
@@ -449,27 +449,27 @@ export interface PluginDefinition {
* Called to open one live Claude `setup-token` login pseudo-terminal.
* The worker registers the terminal under the host route identifier and returns a
* worker session identifier for the output notification binding only. The worker
* streams output and the exit through `ctx.setupTokenPty`, never as a reply.
* Defining the four `onSetupTokenPty*` hooks advertises the four methods.
* streams output and the exit through `ctx.loginPty`, never as a reply.
* Defining the four `onLoginPty*` hooks advertises the four methods.
*/
onSetupTokenPtyOpen?(
params: PluginSetupTokenPtyOpenParams,
): Promise<PluginSetupTokenPtyOpenResult>;
onLoginPtyOpen?(
params: PluginLoginPtyOpenParams,
): Promise<PluginLoginPtyOpenResult>;
/** Called to write delayed input to an open login pseudo-terminal, keyed by the worker session identifier. */
onSetupTokenPtyInput?(params: PluginSetupTokenPtyInputParams): Promise<void>;
onLoginPtyInput?(params: PluginLoginPtyInputParams): Promise<void>;
/** Called to stop an open login pseudo-terminal child, keyed by the worker session identifier. */
onSetupTokenPtyStop?(params: PluginSetupTokenPtyStopParams): Promise<void>;
onLoginPtyStop?(params: PluginLoginPtyStopParams): Promise<void>;
/**
* Called to close an open login pseudo-terminal by the host route identifier. The
* worker closes the exact terminal registered under that identifier and returns a
* close acknowledgement that carries the same identifier.
*/
onSetupTokenPtyClose?(
params: PluginSetupTokenPtyCloseParams,
): Promise<PluginSetupTokenPtyCloseResult>;
onLoginPtyClose?(
params: PluginLoginPtyCloseParams,
): Promise<PluginLoginPtyCloseResult>;
/**
* Called to open one persistent duplex channel. The worker registers the
+2 -2
View File
@@ -82,8 +82,8 @@ export {
parseMessage,
JsonRpcParseError,
JsonRpcCallError,
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
LOGIN_PTY_OUTPUT_NOTIFICATION,
LOGIN_PTY_EXIT_NOTIFICATION,
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
_resetIdCounter,
+52 -29
View File
@@ -972,7 +972,7 @@ export interface PluginRenderCloseEvent {
}
// ---------------------------------------------------------------------------
// Setup-token login pseudo-terminal (PTY) worker methods.
// Login pseudo-terminal (PTY) worker methods.
// ---------------------------------------------------------------------------
// The host drives one live Claude `setup-token` login pseudo-terminal inside a
// sandbox provider worker. The host owns the route. It mints an opaque host
@@ -985,11 +985,19 @@ export interface PluginRenderCloseEvent {
// output and exit as notifications, never as a reply, so the host binds them by
// the worker session identifier while the route is open.
/**
* The closed set of login command identities. The host resolves the key from the
* trusted adapter type and carries it in the open request. The worker maps the
* key to a compile-time command. The open request carries no command string, so a
* caller cannot select or override the command.
*/
export type PluginLoginCommandKey = "claude" | "codex";
/** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */
export interface PluginSetupTokenPtyOpenParams {
export interface PluginLoginPtyOpenParams {
/** The host-owned opaque route identifier. The worker registers the terminal by it. */
hostRouteId: string;
/** The environment driver key, for the worker sandbox scope. */
/** The environment driver key, for the worker sandbox scope. It routes the worker; it confers no command authority. */
driverKey: string;
/** The company that owns the login session. */
companyId: string;
@@ -997,18 +1005,27 @@ export interface PluginSetupTokenPtyOpenParams {
environmentId: string;
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
providerLeaseId: string;
/** The fixed login command. The worker runs only this command on the terminal. */
command: string;
/**
* The host-resolved fixed command identity. The worker maps it to a
* compile-time command. The open request carries no command string.
*/
loginCommandKey: PluginLoginCommandKey;
/**
* The server-controlled, validated session home. The shape is exact:
* `/tmp/paperclip-adapter-login/<uuid>`. The worker revalidates the shape
* before it touches the filesystem.
*/
sessionHome: string;
}
/** The open reply. It returns the worker session identifier for output binding only. */
export interface PluginSetupTokenPtyOpenResult {
export interface PluginLoginPtyOpenResult {
/** The worker session identifier. It binds the output and the exit notification only. */
workerSessionId: string;
}
/** The input request. It carries the worker session identifier and the raw input bytes. */
export interface PluginSetupTokenPtyInputParams {
export interface PluginLoginPtyInputParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The raw input bytes to write to the terminal. */
@@ -1016,13 +1033,13 @@ export interface PluginSetupTokenPtyInputParams {
}
/** The stop request. It carries the worker session identifier. */
export interface PluginSetupTokenPtyStopParams {
export interface PluginLoginPtyStopParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
}
/** The close request. The host route identifier is the authoritative key. */
export interface PluginSetupTokenPtyCloseParams {
export interface PluginLoginPtyCloseParams {
/**
* The host-owned opaque route identifier. This is the authoritative close key,
* so the host closes the terminal even when no worker session identifier
@@ -1038,13 +1055,13 @@ export interface PluginSetupTokenPtyCloseParams {
}
/** The close reply. It acknowledges the close and carries the same host route identifier. */
export interface PluginSetupTokenPtyCloseResult {
export interface PluginLoginPtyCloseResult {
/** The close acknowledgement. It carries the same host route identifier the close sent. */
hostRouteId: string;
}
/** The worker→host pseudo-terminal output notification parameters. Modeled on `execute.log`. */
export interface PluginSetupTokenPtyOutputParams {
export interface PluginLoginPtyOutputParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The raw terminal output bytes. */
@@ -1052,7 +1069,7 @@ export interface PluginSetupTokenPtyOutputParams {
}
/** The worker→host pseudo-terminal exit notification parameters. */
export interface PluginSetupTokenPtyExitParams {
export interface PluginLoginPtyExitParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The child exit code, or null when the child ended with no code. */
@@ -1061,10 +1078,10 @@ export interface PluginSetupTokenPtyExitParams {
/**
* One live login pseudo-terminal session in the worker. The worker opener returns
* it. The shape matches the sandbox provider setup-token pseudo-terminal session,
* it. The shape matches the sandbox provider login pseudo-terminal session,
* so a provider passes its session with no adapter.
*/
export interface PluginSetupTokenPtyWorkerSession {
export interface PluginLoginPtyWorkerSession {
/** Registers the one output listener. The session streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Writes raw input bytes to the pseudo-terminal. */
@@ -1078,9 +1095,9 @@ export interface PluginSetupTokenPtyWorkerSession {
}
/** The worker→host notification method for one pseudo-terminal output chunk. */
export const SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION = "setupTokenPty.output";
export const LOGIN_PTY_OUTPUT_NOTIFICATION = "loginPty.output";
/** The worker→host notification method for one pseudo-terminal exit. */
export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
export const LOGIN_PTY_EXIT_NOTIFICATION = "loginPty.exit";
// ---------------------------------------------------------------------------
// Generic duplex channel worker methods.
@@ -1088,7 +1105,7 @@ export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
// The host drives one persistent duplex channel inside a sandbox provider
// worker. The channel replaces the file transport of the sandbox callback bridge
// with one live bidirectional stream. These messages are generic. They model the
// setup-token pseudo-terminal contract above, but they carry no login command
// login pseudo-terminal contract above, but they carry no login command
// allowlist. The host owns the route. It mints an opaque host route identifier,
// carries that identifier in the open request, and keys the close on that
// identifier. The worker registers the channel under the host route identifier
@@ -1191,6 +1208,12 @@ export interface PluginDuplexChannelExitParams {
workerSessionId: string;
/** The child exit code, or null when the child ended with no code. */
exitCode: number | null;
/**
* True when the provider transport closed with no exit data, so the exit is a
* reason-less transport close, not a process exit. Absent or false marks a real
* process exit. The host maps a transport close to a distinct loss reason.
*/
transportClosed?: boolean;
}
/** The worker→host notification method for one duplex channel data chunk. */
@@ -1303,18 +1326,18 @@ export interface HostToWorkerMethods {
result: PluginEnvironmentDeleteTemplateResult,
];
/** Open one live login pseudo-terminal keyed by a host-owned route identifier. */
setupTokenPtyOpen: [
params: PluginSetupTokenPtyOpenParams,
result: PluginSetupTokenPtyOpenResult,
loginPtyOpen: [
params: PluginLoginPtyOpenParams,
result: PluginLoginPtyOpenResult,
];
/** Write delayed input to a live login pseudo-terminal, keyed by the worker session identifier. */
setupTokenPtyInput: [params: PluginSetupTokenPtyInputParams, result: void];
loginPtyInput: [params: PluginLoginPtyInputParams, result: void];
/** Stop a live login pseudo-terminal child, keyed by the worker session identifier. */
setupTokenPtyStop: [params: PluginSetupTokenPtyStopParams, result: void];
loginPtyStop: [params: PluginLoginPtyStopParams, result: void];
/** Close a live login pseudo-terminal by the host route identifier and return a bound acknowledgement. */
setupTokenPtyClose: [
params: PluginSetupTokenPtyCloseParams,
result: PluginSetupTokenPtyCloseResult,
loginPtyClose: [
params: PluginLoginPtyCloseParams,
result: PluginLoginPtyCloseResult,
];
/** Open one persistent duplex channel keyed by a host-owned route identifier. */
duplexChannelOpen: [
@@ -1371,10 +1394,10 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
"environmentCaptureTemplate",
"environmentCancelInteractiveSetup",
"environmentDeleteTemplate",
"setupTokenPtyOpen",
"setupTokenPtyInput",
"setupTokenPtyStop",
"setupTokenPtyClose",
"loginPtyOpen",
"loginPtyInput",
"loginPtyStop",
"loginPtyClose",
"duplexChannelOpen",
"duplexChannelWrite",
"duplexChannelStop",
+1 -1
View File
@@ -2478,7 +2478,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
// No-op in test harness — the host runner log sink is not wired here.
},
},
setupTokenPty: {
loginPty: {
output(_workerSessionId: string, _chunk: string) {
// No-op in test harness — the host login route is not wired here.
},
+11 -5
View File
@@ -2009,7 +2009,7 @@ export interface PluginExecutionClient {
}
/**
* `ctx.setupTokenPty` — stream one live login pseudo-terminal's output and exit
* `ctx.loginPty` — stream one live login pseudo-terminal's output and exit
* from a sandbox provider worker to the host.
*
* The worker opener registers the output listener on the session and forwards
@@ -2020,7 +2020,7 @@ export interface PluginExecutionClient {
* chunk or an exit that carries an unknown or a mismatched identifier, and it
* never logs the raw bytes. The default is a no-op that never throws.
*/
export interface PluginSetupTokenPtyClient {
export interface PluginLoginPtyClient {
/**
* Deliver one raw output chunk of a live login pseudo-terminal.
*
@@ -2048,7 +2048,7 @@ export interface PluginSetupTokenPtyClient {
* by that identifier while the route is open. The host drops a chunk or an exit
* that carries an unknown or a mismatched identifier, and it never logs the raw
* bytes. The default is a no-op that never throws. This client models the
* `setupTokenPty` client, but it carries no login command allowlist.
* `loginPty` client, but it carries no login command allowlist.
*/
export interface PluginDuplexChannelClient {
/**
@@ -2065,8 +2065,14 @@ export interface PluginDuplexChannelClient {
* @param hostRouteId - The host route identifier the open request carried. The worker echoes it, so the host routes the exact pair.
* @param workerSessionId - The worker session identifier the open reply returned.
* @param exitCode - The child exit code, or null when the child ended with no code.
* @param transportClosed - True when the transport closed with no exit data, so the exit is a reason-less transport close, not a process exit. Absent marks a real process exit.
*/
exit(hostRouteId: string, workerSessionId: string, exitCode: number | null): void;
exit(
hostRouteId: string,
workerSessionId: string,
exitCode: number | null,
transportClosed?: boolean,
): void;
}
// ---------------------------------------------------------------------------
@@ -2187,7 +2193,7 @@ export interface PluginContext {
/** Stream one live login pseudo-terminal's output and exit to the host.
* The default is a no-op for a provider that opens no login
* pseudo-terminal. */
setupTokenPty: PluginSetupTokenPtyClient;
loginPty: PluginLoginPtyClient;
/** Stream one persistent duplex channel's data and exit to the host. The
* default is a no-op for a provider that opens no duplex channel. */
+46 -39
View File
@@ -100,10 +100,10 @@ import type {
PluginEnvironmentCaptureTemplateParams,
PluginEnvironmentCancelInteractiveSetupParams,
PluginEnvironmentDeleteTemplateParams,
PluginSetupTokenPtyOpenParams,
PluginSetupTokenPtyInputParams,
PluginSetupTokenPtyStopParams,
PluginSetupTokenPtyCloseParams,
PluginLoginPtyOpenParams,
PluginLoginPtyInputParams,
PluginLoginPtyStopParams,
PluginLoginPtyCloseParams,
PluginDuplexChannelOpenParams,
PluginDuplexChannelWriteParams,
PluginDuplexChannelStopParams,
@@ -113,8 +113,8 @@ import type {
WorkerToHostMethods,
} from "./protocol.js";
import {
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
LOGIN_PTY_OUTPUT_NOTIFICATION,
LOGIN_PTY_EXIT_NOTIFICATION,
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
JSONRPC_VERSION,
@@ -1378,7 +1378,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
setupTokenPty: {
loginPty: {
output(workerSessionId: string, chunk: string): void {
// Forward one raw output chunk of a live login pseudo-terminal. The
// notification carries the worker session identifier, so the host binds
@@ -1388,14 +1388,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
// because it fires after the open reply returns.
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
if (typeof chunk !== "string" || chunk.length === 0) return;
notifyHost(SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
notifyHost(LOGIN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
},
exit(workerSessionId: string, exitCode: number | null): void {
// Forward the child exit of a live login pseudo-terminal. The host
// resolves the open route's wait promise by the worker session
// identifier while the route is open.
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
notifyHost(SETUP_TOKEN_PTY_EXIT_NOTIFICATION, {
notifyHost(LOGIN_PTY_EXIT_NOTIFICATION, {
workerSessionId,
exitCode: typeof exitCode === "number" ? exitCode : null,
});
@@ -1415,16 +1415,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (typeof chunk !== "string" || chunk.length === 0) return;
notifyHost(DUPLEX_CHANNEL_DATA_NOTIFICATION, { hostRouteId, workerSessionId, chunk });
},
exit(hostRouteId: string, workerSessionId: string, exitCode: number | null): void {
exit(
hostRouteId: string,
workerSessionId: string,
exitCode: number | null,
transportClosed?: boolean,
): void {
// Forward the child exit of a persistent duplex channel. The host
// resolves the open route's wait promise by the exact live pair while
// the route is open.
// the route is open. `transportClosed` carries the exit discriminator, so
// the host tells a real process exit from a reason-less transport close.
if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return;
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
notifyHost(DUPLEX_CHANNEL_EXIT_NOTIFICATION, {
hostRouteId,
workerSessionId,
exitCode: typeof exitCode === "number" ? exitCode : null,
transportClosed: transportClosed === true,
});
},
},
@@ -1645,17 +1652,17 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
case "environmentDeleteTemplate":
return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams);
case "setupTokenPtyOpen":
return handleSetupTokenPtyOpen(params as PluginSetupTokenPtyOpenParams);
case "loginPtyOpen":
return handleLoginPtyOpen(params as PluginLoginPtyOpenParams);
case "setupTokenPtyInput":
return handleSetupTokenPtyInput(params as PluginSetupTokenPtyInputParams);
case "loginPtyInput":
return handleLoginPtyInput(params as PluginLoginPtyInputParams);
case "setupTokenPtyStop":
return handleSetupTokenPtyStop(params as PluginSetupTokenPtyStopParams);
case "loginPtyStop":
return handleLoginPtyStop(params as PluginLoginPtyStopParams);
case "setupTokenPtyClose":
return handleSetupTokenPtyClose(params as PluginSetupTokenPtyCloseParams);
case "loginPtyClose":
return handleLoginPtyClose(params as PluginLoginPtyCloseParams);
case "duplexChannelOpen":
return handleDuplexChannelOpen(params as PluginDuplexChannelOpenParams);
@@ -1720,10 +1727,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate");
if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup");
if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate");
if (plugin.definition.onSetupTokenPtyOpen) supportedMethods.push("setupTokenPtyOpen");
if (plugin.definition.onSetupTokenPtyInput) supportedMethods.push("setupTokenPtyInput");
if (plugin.definition.onSetupTokenPtyStop) supportedMethods.push("setupTokenPtyStop");
if (plugin.definition.onSetupTokenPtyClose) supportedMethods.push("setupTokenPtyClose");
if (plugin.definition.onLoginPtyOpen) supportedMethods.push("loginPtyOpen");
if (plugin.definition.onLoginPtyInput) supportedMethods.push("loginPtyInput");
if (plugin.definition.onLoginPtyStop) supportedMethods.push("loginPtyStop");
if (plugin.definition.onLoginPtyClose) supportedMethods.push("loginPtyClose");
if (plugin.definition.onDuplexChannelOpen) supportedMethods.push("duplexChannelOpen");
if (plugin.definition.onDuplexChannelWrite) supportedMethods.push("duplexChannelWrite");
if (plugin.definition.onDuplexChannelStop) supportedMethods.push("duplexChannelStop");
@@ -2076,32 +2083,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return plugin.definition.onEnvironmentDeleteTemplate(params);
}
async function handleSetupTokenPtyOpen(params: PluginSetupTokenPtyOpenParams) {
if (!plugin.definition.onSetupTokenPtyOpen) {
throw methodNotImplemented("setupTokenPtyOpen");
async function handleLoginPtyOpen(params: PluginLoginPtyOpenParams) {
if (!plugin.definition.onLoginPtyOpen) {
throw methodNotImplemented("loginPtyOpen");
}
return plugin.definition.onSetupTokenPtyOpen(params);
return plugin.definition.onLoginPtyOpen(params);
}
async function handleSetupTokenPtyInput(params: PluginSetupTokenPtyInputParams) {
if (!plugin.definition.onSetupTokenPtyInput) {
throw methodNotImplemented("setupTokenPtyInput");
async function handleLoginPtyInput(params: PluginLoginPtyInputParams) {
if (!plugin.definition.onLoginPtyInput) {
throw methodNotImplemented("loginPtyInput");
}
return plugin.definition.onSetupTokenPtyInput(params);
return plugin.definition.onLoginPtyInput(params);
}
async function handleSetupTokenPtyStop(params: PluginSetupTokenPtyStopParams) {
if (!plugin.definition.onSetupTokenPtyStop) {
throw methodNotImplemented("setupTokenPtyStop");
async function handleLoginPtyStop(params: PluginLoginPtyStopParams) {
if (!plugin.definition.onLoginPtyStop) {
throw methodNotImplemented("loginPtyStop");
}
return plugin.definition.onSetupTokenPtyStop(params);
return plugin.definition.onLoginPtyStop(params);
}
async function handleSetupTokenPtyClose(params: PluginSetupTokenPtyCloseParams) {
if (!plugin.definition.onSetupTokenPtyClose) {
throw methodNotImplemented("setupTokenPtyClose");
async function handleLoginPtyClose(params: PluginLoginPtyCloseParams) {
if (!plugin.definition.onLoginPtyClose) {
throw methodNotImplemented("loginPtyClose");
}
return plugin.definition.onSetupTokenPtyClose(params);
return plugin.definition.onLoginPtyClose(params);
}
async function handleDuplexChannelOpen(params: PluginDuplexChannelOpenParams) {
@@ -774,28 +774,32 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
let killed = 0;
let closed = 0;
// The worker emits output and exit through `ctx.setupTokenPty`, bound to the
// The worker emits output and exit through `ctx.loginPty`, bound to the
// worker session id. The test drives them through the captured emitters.
const controllablePlugin = definePlugin({
async setup(ctx) {
emitOutput = (chunk: string) => ctx.setupTokenPty.output("ws-1", chunk);
resolveWait = (value) => ctx.setupTokenPty.exit("ws-1", value.exitCode);
emitOutput = (chunk: string) => ctx.loginPty.output("ws-1", chunk);
resolveWait = (value) => ctx.loginPty.exit("ws-1", value.exitCode);
},
async onSetupTokenPtyOpen(params) {
// The open carries the host route id and the fixed command. The worker
// returns a worker session id for the output binding only.
async onLoginPtyOpen(params) {
// The open carries the host route id, the closed command key, and the
// validated session home. The worker returns a worker session id for the
// output binding only. The open carries no command string.
expect(params.hostRouteId).toBe("route-1");
expect(params.command).toBe("claude setup-token");
expect(params.loginCommandKey).toBe("claude");
expect(params.sessionHome).toBe(
"/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
);
expect(params.providerLeaseId).toBe("lease-1");
return { workerSessionId: "ws-1" };
},
async onSetupTokenPtyInput(params) {
async onLoginPtyInput(params) {
inputs.push(params.data);
},
async onSetupTokenPtyStop() {
async onLoginPtyStop() {
killed += 1;
},
async onSetupTokenPtyClose(params) {
async onLoginPtyClose(params) {
// The close keys on the host route id and returns a bound acknowledgement.
closed += 1;
return { hostRouteId: params.hostRouteId };
@@ -839,10 +843,10 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
await expect(
callWorker("initialize", {
manifest: {
id: "paperclip.setup-token-pty",
id: "paperclip.login-pty",
apiVersion: 1,
version: "1.0.0",
displayName: "Setup Token PTY Test",
displayName: "Login PTY Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
@@ -855,31 +859,32 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
).resolves.toMatchObject({
ok: true,
supportedMethods: expect.arrayContaining([
"setupTokenPtyOpen",
"setupTokenPtyInput",
"setupTokenPtyStop",
"setupTokenPtyClose",
"loginPtyOpen",
"loginPtyInput",
"loginPtyStop",
"loginPtyClose",
]),
});
await expect(
callWorker("setupTokenPtyOpen", {
callWorker("loginPtyOpen", {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "claude setup-token",
loginCommandKey: "claude",
sessionHome: "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
}),
).resolves.toEqual({ workerSessionId: "ws-1" });
// The worker streams output as a notification bound to the worker session id.
emitOutput?.("prompt output");
await callWorker("setupTokenPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
await callWorker("setupTokenPtyStop", { workerSessionId: "ws-1" });
await callWorker("loginPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
await callWorker("loginPtyStop", { workerSessionId: "ws-1" });
resolveWait?.({ exitCode: 0 });
await expect(
callWorker("setupTokenPtyClose", { hostRouteId: "route-1" }),
callWorker("loginPtyClose", { hostRouteId: "route-1" }),
).resolves.toEqual({ hostRouteId: "route-1" });
await new Promise((resolve) => setImmediate(resolve));
@@ -888,13 +893,13 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
expect(killed).toBe(1);
expect(closed).toBe(1);
const outputNotes = notifications.filter(
(note) => note.method === "setupTokenPty.output",
(note) => note.method === "loginPty.output",
);
expect(outputNotes.map((note) => note.params)).toEqual([
{ workerSessionId: "ws-1", chunk: "prompt output" },
]);
const exitNotes = notifications.filter(
(note) => note.method === "setupTokenPty.exit",
(note) => note.method === "loginPty.exit",
);
expect(exitNotes.map((note) => note.params)).toEqual([
{ workerSessionId: "ws-1", exitCode: 0 },
+19
View File
@@ -350,6 +350,24 @@ field.
| `sandbox_duplex_loss_total` | One terminal duplex channel loss. The `loss_class` dimension records the phase. |
| `sandbox_duplex_session_leak_total` | One leaked provider session at teardown. |
### Aggregate byte ledger metrics
The host aggregate byte ledger owns one process-scoped gauge and two
process-scoped counters. The ledger bounds the retained bytes across every live
duplex route in one process. It sets the gauge on each reserve and each release.
It increments a counter on a rejected reservation and on an accounting defect.
These records carry no dimension label. The guarded counter store keys each
counter on `(companyId, metric)`, and the gauge reports one process value, so no
dynamic dimension rides them. The code owner is
`packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts`, and the metric
names are literal constants in `duplex-telemetry.ts`.
| Metric | Type | Scope |
| --- | --- | --- |
| `sandbox_duplex_aggregate_bytes_in_use` | gauge | The aggregate retained bytes across every live duplex route. The ledger sets it on each reserve and each release. |
| `sandbox_duplex_aggregate_byte_reservation_rejections_total` | counter | One rejected aggregate byte reservation. The ledger increments it when a reservation would pass the aggregate ceiling. |
| `sandbox_duplex_aggregate_byte_accounting_underflow_total` | counter | One aggregate byte accounting defect. The ledger increments it on a double release or on a transfer of a token it does not hold. |
### Dimension keys
Counters carry no dimension labels. The guarded counter store keys each counter
@@ -366,6 +384,7 @@ key never reaches a sink by accident.
| `outcome` | string | yes | `ok` or `error`. |
| `fallback_reason` | string | yes | `gate_off`, `capability_absent`, `open_failed`, `ready_invalid`, `ready_nonce_mismatch`, `ready_timeout`, or `contaminated`. It rides only a fallback record. |
| `loss_class` | string | yes | `pre_dispatch` or `post_dispatch`, relative to the first request dispatch. It rides only a loss record. |
| `loss_reason` | string | yes | `stdin_eof`, `provider_exit`, `heartbeat_timeout`, `rpc_failure`, `write_error`, `transport_closed`, or `other`. The host maps every loss cause to one of these values, so no raw provider text reaches a sink. `write_error` marks a rejected host-to-sandbox write. `transport_closed` marks a reason-less provider transport close with no exit data. It rides only a loss record. |
To add a name or an enum value, extend the literal constant in
`duplex-telemetry.ts` first, then update the test that asserts the closed set.
+1 -1
View File
@@ -35,7 +35,7 @@
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "pnpm --filter @paperclipai/paperclip-runner build:typescript && tsc && mkdir -p dist/onboarding-assets dist/built-ins && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && node scripts/vendor-paperclip-runner.mjs && node scripts/write-build-stamp.mjs",
"build": "pnpm --filter @paperclipai/paperclip-runner build:typescript && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && node scripts/vendor-paperclip-runner.mjs && node scripts/write-build-stamp.mjs",
"prepack": "pnpm run prepare:ui-dist && node scripts/vendor-paperclip-runner.mjs",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
@@ -73,6 +73,10 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
}));
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
// The provider capability resolver the device-login gate reads. A supported
// provider advertises the login pseudo-terminal capability. A test overrides this
// to prove an unsupported provider fails closed before any session or lease.
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}));
@@ -136,6 +140,16 @@ vi.mock("../services/environment-runtime.js", () => ({
environmentRuntimeService: () => mockEnvironmentRuntime,
}));
vi.mock("../services/plugin-environment-driver.js", async () => {
const actual = await vi.importActual<typeof import("../services/plugin-environment-driver.js")>(
"../services/plugin-environment-driver.js",
);
return {
...actual,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
};
});
vi.mock("../services/environment-execution-target.js", () => ({
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
}));
@@ -263,8 +277,8 @@ function createFakeRuntime(): LoginSessionRuntime {
providerLeaseId: `provider-lease-${input.sessionId}`,
authPath: `/tmp/paperclip-adapter-login/${input.sessionId}/auth.json`,
driver: {
async execStreaming(_command, onStdout) {
onStdout(PROMPT_OUTPUT);
async start(_command, onData) {
onData(PROMPT_OUTPUT);
await harness.gate;
return { exitCode: 0 };
},
@@ -340,6 +354,14 @@ describe("adapter device-login routes", () => {
config: { provider: "daytona" },
envVars: {},
}));
// The default provider advertises the login pseudo-terminal capability. A
// test overrides this to prove an unsupported provider fails closed.
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
async ({ driverKey }: { driverKey: string }) =>
driverKey === "daytona"
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: true } }
: null,
);
});
afterEach(() => {
@@ -396,10 +418,10 @@ describe("adapter device-login routes", () => {
expect(store.rows.get(res.body.sessionId)).toBeUndefined();
});
it("rejects an adapter whose login capability drives a different transport", async () => {
// The Claude adapter declares a pseudo-terminal login, not a streamed-exec
// device login. The guard reads the capability transport, so it rejects the
// adapter with a fixed 400 before any lease.
it("rejects an adapter whose login capability drives a different panel mode", async () => {
// The Claude adapter declares a submitted-browser-code login, not a
// displayed-code device login. The guard reads the capability panel mode, so
// it rejects the adapter with a fixed 400 before any lease.
const app = await createApp();
const res = await request(app)
@@ -410,12 +432,14 @@ describe("adapter device-login routes", () => {
expect(harness.acquisitions).toHaveLength(0);
});
it("starts a device login for a third adapter that declares the streamed-exec capability", async () => {
// A third adapter, not the Codex adapter, declares a streamed-exec login
// capability. The guard reads the registry capability, not the adapter name,
// so the adapter passes the guard and starts a session. This proves no
// adapter-name branch remains in the guard path. The test overrides an
// existing adapter type so the strict request schema accepts it.
it("rejects a displayed-code adapter that has no mapped login command key", async () => {
// A third adapter declares a displayed-code login capability, but its adapter
// type maps to no login command key. The login opener resolves the command
// key from the closed command map, so this adapter would fail at command
// resolution after the route creates session state. The guard keeps admission
// consistent with the command map, so it rejects the adapter with a fixed 400
// before any lease. The test overrides an existing adapter type so the strict
// request schema accepts it.
const app = await createApp();
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({
@@ -428,7 +452,6 @@ describe("adapter device-login routes", () => {
},
loginCapability: {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
@@ -439,15 +462,74 @@ describe("adapter device-login routes", () => {
.post(loginPath(COMPANY_1, "gemini_local"))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
expect(harness.acquisitions).toHaveLength(1);
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "gemini_local" });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(harness.acquisitions).toHaveLength(0);
} finally {
unregisterServerAdapter("gemini_local");
}
});
it("starts a device login for a non-Codex adapter that maps to a login command key", async () => {
// A non-Codex adapter type that maps to a login command key declares a
// displayed-code login capability. The guard reads the registry capability
// and the command map, not the adapter name, so the adapter passes the guard
// and starts a session. This proves no adapter-name branch remains in the
// guard path. The test overrides the mapped `claude_local` type with a
// displayed-code capability so the guard admits it.
const app = await createApp();
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({
type: "claude_local",
execute: async () => {
throw new Error("not used");
},
testEnvironment: async () => {
throw new Error("not used");
},
loginCapability: {
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
},
});
try {
const res = await request(app)
.post(loginPath(COMPANY_1, "claude_local"))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
expect(harness.acquisitions).toHaveLength(1);
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "claude_local" });
} finally {
unregisterServerAdapter("claude_local");
}
});
it("rejects a provider that does not advertise the login pseudo-terminal capability", async () => {
// The device login runs on a real pseudo-terminal, so it needs a provider
// that advertises the login pseudo-terminal capability. The provider resolves
// to a driver with no capability, so the route gate fails closed before any
// session row or lease.
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
async ({ driverKey }: { driverKey: string }) =>
driverKey === "daytona"
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: false } }
: null,
);
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body).toMatchObject({ code: "codex_device_login_provider_unsupported" });
// The gate ran before any session row or lease, so the runtime acquired none.
expect(harness.acquisitions).toHaveLength(0);
});
it("rejects a malformed start body with the strict schema before any side effect", async () => {
const app = await createApp();
@@ -0,0 +1,307 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, chmodSync, linkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
AUTH_JSON_FILE_NAME,
DEVICE_LOGIN_AUTH_READ_ERROR,
DEVICE_LOGIN_AUTH_READ_SCRIPT,
MAX_AUTH_JSON_BYTES,
decodeAuthReadOutput,
runDescriptorBoundAuthRead,
} from "../services/codex-device-login-credential-read.ts";
// The helper runs on node and walks the path with `/proc/self/fd`, which is
// Linux only. A non-Linux host skips the script tests and keeps the pure-decode
// tests. The login sandbox is Linux, so the walk runs there.
const scriptRunnable = process.platform === "linux";
const describeLinux = scriptRunnable ? describe : describe.skip;
if (!scriptRunnable) {
console.warn(
"Skipping descriptor-bound read script tests on this host: the /proc walk needs Linux.",
);
}
describe("decodeAuthReadOutput", () => {
it("decodes strict base64 to the exact bytes", () => {
const payload = Buffer.from('{"tokens":{"refresh_token":"r"}}', "utf8");
const decoded = decodeAuthReadOutput(payload.toString("base64"));
expect(decoded.equals(payload)).toBe(true);
});
it("tolerates a trailing newline from the helper", () => {
const payload = Buffer.from("abc", "utf8");
const decoded = decodeAuthReadOutput(`${payload.toString("base64")}\n`);
expect(decoded.toString("utf8")).toBe("abc");
});
it("returns an empty buffer for empty output", () => {
expect(decodeAuthReadOutput("").length).toBe(0);
});
it("rejects a non-base64 payload", () => {
expect(() => decodeAuthReadOutput("not*base64*text")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
});
it("rejects a wrong-padding payload", () => {
// A single stray character is not a multiple of four, so it is malformed.
expect(() => decodeAuthReadOutput("QQ")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
});
it("rejects a decoded payload over the bound", () => {
const oversize = Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61).toString("base64");
expect(() => decodeAuthReadOutput(oversize)).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
});
});
describeLinux("the descriptor-bound read helper script", () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), "codex-auth-read-"));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// Run the helper against `home`. An explicit `expectedUid` forces the owner
// check; the production caller omits it and the helper uses its own uid.
function runScript(home: string, expectedUid?: number): { status: number | null; stdout: string } {
const args = ["-e", DEVICE_LOGIN_AUTH_READ_SCRIPT, home, String(MAX_AUTH_JSON_BYTES)];
if (expectedUid !== undefined) args.push(String(expectedUid));
const result = spawnSync("node", args, { encoding: "utf8" });
return { status: result.status, stdout: result.stdout ?? "" };
}
// Build a fresh session home with a `0700` directory. The caller writes the
// credential file inside it.
function makeHome(name: string): string {
const home = path.join(root, name);
mkdirSync(home, { mode: 0o700 });
return home;
}
it("reads a regular 0600 credential owned by the login user", () => {
const home = makeHome("valid");
const payload = '{"tokens":{"refresh_token":"r"}}';
const file = path.join(home, AUTH_JSON_FILE_NAME);
writeFileSync(file, payload, { mode: 0o600 });
chmodSync(file, 0o600);
const { status, stdout } = runScript(home);
expect(status).toBe(0);
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe(payload);
});
it("rejects a missing credential", () => {
const home = makeHome("missing");
expect(runScript(home).status).not.toBe(0);
});
it("rejects a symlink credential (a swap between the check and the read)", () => {
// The attacker swaps `auth.json` for a symlink to a file outside the home.
// The single no-follow open rejects the symlink, so the read never follows
// it. There is no separate check to race.
const home = makeHome("symlink");
const secret = path.join(root, "outside-secret");
writeFileSync(secret, "outside", { mode: 0o600 });
symlinkSync(secret, path.join(home, AUTH_JSON_FILE_NAME));
expect(runScript(home).status).not.toBe(0);
});
it("rejects a directory in the credential position", () => {
const home = makeHome("dir");
mkdirSync(path.join(home, AUTH_JSON_FILE_NAME), { mode: 0o700 });
expect(runScript(home).status).not.toBe(0);
});
it("rejects a wrong-mode credential", () => {
const home = makeHome("mode");
const file = path.join(home, AUTH_JSON_FILE_NAME);
writeFileSync(file, "{}", { mode: 0o644 });
chmodSync(file, 0o644);
expect(runScript(home).status).not.toBe(0);
});
it("rejects a wrong-owner credential", () => {
const home = makeHome("owner");
const file = path.join(home, AUTH_JSON_FILE_NAME);
writeFileSync(file, "{}", { mode: 0o600 });
chmodSync(file, 0o600);
// Force a uid that differs from the file owner. The check rejects it.
const bogusUid = process.getuid ? process.getuid()! + 1 : 999999;
expect(runScript(home, bogusUid).status).not.toBe(0);
});
it("rejects an oversize credential", () => {
const home = makeHome("oversize");
const file = path.join(home, AUTH_JSON_FILE_NAME);
writeFileSync(file, Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61), { mode: 0o600 });
chmodSync(file, 0o600);
expect(runScript(home).status).not.toBe(0);
});
it("rejects a symlink session home", () => {
const real = makeHome("real-home");
const file = path.join(real, AUTH_JSON_FILE_NAME);
writeFileSync(file, "{}", { mode: 0o600 });
chmodSync(file, 0o600);
const link = path.join(root, "home-symlink");
symlinkSync(real, link);
expect(runScript(link).status).not.toBe(0);
});
it("rejects an intermediate-path symlink ancestor and never follows it", () => {
// The attacker swaps an ancestor directory of the session home for a symlink
// to an attacker-controlled tree. That tree holds a valid-looking 0600
// credential. A single composite-path open with `O_NOFOLLOW` would follow the
// ancestor symlink, because `O_NOFOLLOW` protects only the final component.
// The per-component walk opens the ancestor with its own no-follow open, so
// the read fails closed and returns no bytes.
const evil = path.join(root, "evil-target");
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
const file = path.join(evil, "session", AUTH_JSON_FILE_NAME);
writeFileSync(file, '{"stolen":true}', { mode: 0o600 });
chmodSync(file, 0o600);
// `ancestor` is a symlink to the attacker tree. The home path routes through it.
const ancestor = path.join(root, "ancestor-symlink");
symlinkSync(evil, ancestor);
const home = path.join(ancestor, "session");
const { status, stdout } = runScript(home);
expect(status).not.toBe(0);
// The read leaked no bytes from the attacker tree.
expect(stdout.trim()).toBe("");
});
it("fails closed when an ancestor is swapped to a symlink between two reads", () => {
// The first read sees a real ancestor and returns the private credential. An
// attacker then swaps the ancestor for a symlink to an attacker tree with a
// valid-looking credential. The second read walks the ancestor with a
// no-follow open, so it fails closed and never returns the attacker bytes.
const base = mkdtempSync(path.join(root, "swap-"));
const mid = path.join(base, "mid");
mkdirSync(path.join(mid, "session"), { recursive: true, mode: 0o700 });
const goodFile = path.join(mid, "session", AUTH_JSON_FILE_NAME);
writeFileSync(goodFile, '{"good":true}', { mode: 0o600 });
chmodSync(goodFile, 0o600);
const home = path.join(mid, "session");
const first = runScript(home);
expect(first.status).toBe(0);
expect(Buffer.from(first.stdout.trim(), "base64").toString("utf8")).toBe('{"good":true}');
// The attacker tree carries a same-shape credential.
const evil = path.join(base, "evil");
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
const evilFile = path.join(evil, "session", AUTH_JSON_FILE_NAME);
writeFileSync(evilFile, '{"evil":true}', { mode: 0o600 });
chmodSync(evilFile, 0o600);
// Swap the ancestor `mid` for a symlink to the attacker tree.
rmSync(mid, { recursive: true, force: true });
symlinkSync(evil, mid);
const second = runScript(home);
expect(second.status).not.toBe(0);
expect(second.stdout.trim()).toBe("");
});
it("reads a hardlink credential inside the private home", () => {
// A hardlink is not a symlink. The no-follow open allows it, and the fstat
// sees a regular file. A hardlink inside the private `0700` home is not a
// privilege escalation, so the read returns the linked inode content.
const home = makeHome("hardlink");
const backing = path.join(home, "backing.json");
writeFileSync(backing, '{"ok":true}', { mode: 0o600 });
chmodSync(backing, 0o600);
linkSync(backing, path.join(home, AUTH_JSON_FILE_NAME));
const { status, stdout } = runScript(home);
expect(status).toBe(0);
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe('{"ok":true}');
});
});
describeLinux("runDescriptorBoundAuthRead", () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), "codex-auth-wrap-"));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// A fake environment runtime that runs the exact command and arguments the
// wrapper composes. It exercises the real helper against a local home, so the
// wrapper and the script are tested together. The session home shape must match
// the validated pattern, so the fake maps the validated home onto a local
// directory before it runs the helper.
function createLocalRuntime(localHome: string) {
return {
async execute(input: { command: string; args?: string[]; bypassSession?: boolean }) {
expect(input.command).toBe("node");
expect(input.bypassSession).toBe(true);
const args = [...(input.args ?? [])];
// Replace the validated session-home argument with the local directory. The
// wrapper composes `["-e", <script>, <sessionHome>, <maxBytes>]`, so the
// session home is the third argument.
args[2] = localHome;
const result = spawnSync("node", args, { encoding: "utf8" });
return { exitCode: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
},
};
}
// A syntactically valid session home. The wrapper revalidates the shape.
const VALID_HOME = "/tmp/paperclip-adapter-login/11111111-1111-1111-1111-111111111111";
it("returns the credential bytes on a valid read", async () => {
const localHome = mkdtempSync(path.join(root, "valid-"));
const payload = '{"tokens":{"refresh_token":"r"}}';
const file = path.join(localHome, AUTH_JSON_FILE_NAME);
writeFileSync(file, payload, { mode: 0o600 });
chmodSync(file, 0o600);
const bytes = await runDescriptorBoundAuthRead({
environmentRuntime: createLocalRuntime(localHome) as never,
environment: { id: "env", driver: "sandbox" } as never,
lease: { id: "lease" } as never,
sessionHome: VALID_HOME,
timeoutMs: 1000,
});
expect(bytes.toString("utf8")).toBe(payload);
});
it("returns the fixed error when the helper exits non-zero", async () => {
const localHome = mkdtempSync(path.join(root, "missing-"));
await expect(
runDescriptorBoundAuthRead({
environmentRuntime: createLocalRuntime(localHome) as never,
environment: { id: "env", driver: "sandbox" } as never,
lease: { id: "lease" } as never,
sessionHome: VALID_HOME,
timeoutMs: 1000,
}),
).rejects.toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
});
it("rejects an invalid session-home shape before it runs the helper", async () => {
let executeCalls = 0;
const runtime = {
async execute() {
executeCalls += 1;
return { exitCode: 0, stdout: "", stderr: "" };
},
};
await expect(
runDescriptorBoundAuthRead({
environmentRuntime: runtime as never,
environment: { id: "env", driver: "sandbox" } as never,
lease: { id: "lease" } as never,
sessionHome: "/etc/passwd",
timeoutMs: 1000,
}),
).rejects.toThrow();
expect(executeCalls).toBe(0);
});
});
@@ -115,6 +115,8 @@ const execHang: ExecBehavior = ({ onStdout }) => {
interface FakeRuntimeOptions {
exec: ExecBehavior;
authBytes?: Buffer;
/** When set, the descriptor-bound read rejects with this error on success. */
readError?: Error;
delete?: () => Promise<SandboxDeleteResult>;
}
@@ -130,8 +132,11 @@ function createFakeRuntime(opts: FakeRuntimeOptions) {
providerLeaseId: `lease-${input.sessionId}`,
authPath: sessionCredentialPath(input.sessionId),
driver: {
execStreaming: (_command, onStdout) => opts.exec({ onStdout, input }),
readFile: async () => opts.authBytes ?? Buffer.from("{}"),
start: (_command, onData) => opts.exec({ onStdout: onData, input }),
readFile: async () => {
if (opts.readError) throw opts.readError;
return opts.authBytes ?? Buffer.from("{}");
},
dispose: async () => {},
},
deleteSandbox: async () => {
@@ -241,7 +246,7 @@ describe("codex device login service", () => {
providerLeaseId: `lease-${input.sessionId}`,
authPath: sessionCredentialPath(input.sessionId),
driver: {
execStreaming: (_command, onStdout) => execSuccess({ onStdout, input }),
start: (_command, onData) => execSuccess({ onStdout: onData, input }),
readFile: async () => Buffer.from("{}"),
dispose: async () => {},
},
@@ -416,6 +421,73 @@ describe("codex device login service", () => {
expect(deleteCalls).toHaveLength(1);
});
it("fails closed and deletes the sandbox when the descriptor-bound read fails", async () => {
const store = createMemoryStore();
let promoteCalls = 0;
const { runtime, deleteCalls } = createFakeRuntime({
// The login command exits 0, but the descriptor-bound read rejects. The
// runner converts the read error to a fixed driver error, so the run fails.
exec: execSuccess,
readError: new Error("device login failed: the sandbox credential read errored."),
});
const service = makeService({
store,
runtime,
promotion: {
promote: () => {
promoteCalls += 1;
},
},
});
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
const outcome = await completed;
// A failed read never promotes a credential and still deletes the sandbox.
expect(outcome.status).toBe("failed");
expect(promoteCalls).toBe(0);
expect(deleteCalls).toHaveLength(1);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("failed");
});
it("fails closed and deletes the sandbox when the credential schema is malformed", async () => {
const store = createMemoryStore();
const { runtime, deleteCalls } = createFakeRuntime({
// The read returns a well-formed file, but the promotion rejects the shape.
// A malformed schema fails the same way as a failed check: no authenticate,
// and a sandbox delete.
exec: execSuccess,
authBytes: Buffer.from('{"not":"a subscription auth"}', "utf8"),
});
const service = makeService({
store,
runtime,
promotion: {
promote: () => {
throw new Error("malformed credential schema");
},
},
});
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
const outcome = await completed;
expect(outcome.status).toBe("failed");
expect(deleteCalls).toHaveLength(1);
const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(failed?.status).toBe("failed");
expect(failed?.failure?.reason).toBe("promotion_failed");
});
it("deletes the sandbox and records a cancelled terminal on a cancellation", async () => {
const store = createMemoryStore();
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
@@ -789,45 +861,53 @@ describe("codex device login service", () => {
});
});
it("builds a production driver that sets an empty session home and reads the fixed credential path", async () => {
// Record the program and the argument vector for each call. The provider
// quotes each element as one shell token, so the driver must pass a program
// plus its arguments, never one compound string. A one-shot exec streams no
// output, so the login command must set `forceSession` to open the session.
const calls: {
command: string;
args?: string[];
forceSession?: boolean;
bypassSession?: boolean;
}[] = [];
it("runs the login over the shared pseudo-terminal and reads the credential with the descriptor-bound read", async () => {
const sessionId = randomUUID();
const sessionHome = sessionCodexHomePath(sessionId);
const authPath = sessionCredentialPath(sessionId);
// The fake pseudo-terminal session streams the prompt and exits with code
// zero. It records the command the transport opened.
let openedCommand: string | null = null;
let closed = false;
const openPtySession = async (command: string) => {
openedCommand = command;
return {
onData(listener: (chunk: string) => void) {
listener(PROMPT_OUTPUT);
},
write() {},
async wait() {
return { exitCode: 0 };
},
kill() {},
async close() {
closed = true;
},
};
};
// The fake runtime runs only the descriptor-bound read. It records the fixed,
// no-follow helper shape and returns the base64 of the credential bytes.
const execCalls: { command: string; args?: string[]; bypassSession?: boolean }[] = [];
const credential = '{"tokens":{"refresh_token":"r"}}';
const environmentRuntime = {
execute: async (input: {
command: string;
args?: string[];
forceSession?: boolean;
bypassSession?: boolean;
onLog?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
}) => {
calls.push({
execute: async (input: { command: string; args?: string[]; bypassSession?: boolean }) => {
execCalls.push({
command: input.command,
args: input.args,
forceSession: input.forceSession,
bypassSession: input.bypassSession,
});
const script = input.args?.join(" ") ?? "";
if (script.includes("codex login")) {
await input.onLog?.("stdout", PROMPT_OUTPUT);
return { exitCode: 0, stdout: "", stderr: "" };
}
// The credential read.
return { exitCode: 0, stdout: '{"token":"secret"}', stderr: "" };
return {
exitCode: 0,
stdout: Buffer.from(credential, "utf8").toString("base64"),
stderr: "",
};
},
};
const driver = buildSandboxLoginDriver({
// The helper only calls `execute`; a partial runtime is enough here.
openPtySession,
environmentRuntime: environmentRuntime as never,
environment: { id: "env", driver: "sandbox" } as never,
lease: { id: "lease" } as never,
@@ -836,25 +916,28 @@ describe("codex device login service", () => {
});
const chunks: string[] = [];
const result = await driver.execStreaming("codex login --device-auth", (chunk) => chunks.push(chunk));
const result = await driver.start("codex login --device-auth", (chunk) => chunks.push(chunk));
expect(result.exitCode).toBe(0);
expect(openedCommand).toBe("codex login --device-auth");
expect(chunks.join("")).toContain(DEVICE_LOGIN_URL);
// The login command runs through `sh -c`, so the runtime runs the whole
// compound command in one shell. It sets an empty session-specific Codex
// home before the login. It opens the session, so the prompt streams.
expect(calls[0].command).toBe("sh");
expect(calls[0].args).toEqual([
"-c",
`rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} codex login --device-auth`,
]);
expect(calls[0].forceSession).toBe(true);
// The login runs over the pseudo-terminal, so the descriptor read did not run
// during the login.
expect(execCalls.length).toBe(0);
// The runner path passes the credential path, but the descriptor-bound read
// ignores it and reads the fixed file under the verified session home.
const authBytes = await driver.readFile(authPath);
expect(authBytes.toString("utf8")).toBe('{"token":"secret"}');
// The credential read runs `cat` with the path as one argument, one-shot.
expect(calls[1].command).toBe("cat");
expect(calls[1].args).toEqual([authPath]);
expect(calls[1].bypassSession).toBe(true);
expect(authBytes.toString("utf8")).toBe(credential);
expect(execCalls.length).toBe(1);
// The read runs the fixed no-follow helper as a one-shot, non-session command.
expect(execCalls[0].command).toBe("node");
expect(execCalls[0].bypassSession).toBe(true);
expect(execCalls[0].args?.[0]).toBe("-e");
// The third argument is the verified session home the helper opens no-follow.
expect(execCalls[0].args?.[2]).toBe(sessionHome);
await driver.dispose();
expect(closed).toBe(true);
});
});
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import type { AgentAdapterType } from "@paperclipai/shared";
// The production runtime resolves the environment through `environmentService`.
// These tests replace it with a fake, so the runtime runs with no database. The
// fake returns a fixed environment and records the lease-metadata tag write.
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
updateLeaseMetadata: vi.fn(),
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
import {
createCodexWorkerBoundLoginPtyOpener,
createProductionLoginSessionRuntime,
sessionCodexHomePath,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
type LoginPtySessionBinding,
} from "../services/codex-device-login-service.js";
// A fake worker pseudo-terminal session. It satisfies the shared transport
// contract, so the opener can return it.
function fakeWorkerSession() {
return {
onData() {},
write() {},
async wait() {
return { exitCode: 0 };
},
kill() {},
async close() {},
};
}
// A verified binding for one login session. The runtime builds it after it
// acquires the lease. The tests set the server-controlled session home.
function makeBinding(overrides: Partial<LoginPtySessionBinding> = {}): LoginPtySessionBinding {
return {
companyId: "company-1",
environmentId: "env-1",
adapterType: "codex_local" as AgentAdapterType,
providerLeaseId: "provider-lease-9",
sessionHome: sessionCodexHomePath(randomUUID()),
environment: { id: "env-1", driver: "sandbox" } as never,
lease: {
id: "lease-1",
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
} as never,
...overrides,
};
}
describe("createCodexWorkerBoundLoginPtyOpener", () => {
it("passes the binding's server-controlled session home to the worker route", async () => {
const sessionHome = sessionCodexHomePath(randomUUID());
const session = fakeWorkerSession();
const openLoginPtySession = vi.fn(
async (_pluginId: string, _input: Record<string, unknown>) => session,
);
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
const opener = await openLivePtySession(makeBinding({ sessionHome }));
// The opener ignores the incoming command argument. It resolves the closed
// command key from the trusted adapter type.
const opened = await opener("caller-supplied-ignored");
expect(openLoginPtySession).toHaveBeenCalledTimes(1);
const [pluginId, input] = openLoginPtySession.mock.calls[0];
expect(pluginId).toBe("paperclip.daytona");
expect(input).toMatchObject({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "provider-lease-9",
loginCommandKey: "codex",
// The opener passes the binding's server-controlled home, not a fresh UUID
// home. This home is the one the descriptor-bound credential read opens, so
// the sandbox `CODEX_HOME` and the read target match.
sessionHome,
});
expect(input).not.toHaveProperty("command");
expect(opened).toBe(session);
});
it("fails closed when the lease carries no sandbox worker binding", async () => {
const openLoginPtySession = vi.fn();
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
await expect(
openLivePtySession(makeBinding({ lease: { id: "lease-1", metadata: {} } as never })),
).rejects.toThrow();
expect(openLoginPtySession).not.toHaveBeenCalled();
});
it("fails closed when the adapter type has no login command key", async () => {
const openLoginPtySession = vi.fn();
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
await expect(
openLivePtySession(makeBinding({ adapterType: "gemini_local" as AgentAdapterType })),
).rejects.toThrow();
expect(openLoginPtySession).not.toHaveBeenCalled();
});
});
describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
const acquireInput = {
companyId: "company-1",
environmentId: "env-1",
adapterType: "codex_local" as AgentAdapterType,
startedByUserId: "user-a",
};
it("re-checks the provider capability and fails before the provider lease when it is unsupported", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: "env-1",
driver: "sandbox",
config: { provider: "daytona" },
});
const acquireRunLease = vi.fn();
// The capability flipped to unsupported after the route gate. The lease gate
// reads the current capability and fails closed.
const assertProviderSupportsLoginPty = vi.fn(async () => {
throw new Error(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
});
const runtime = createProductionLoginSessionRuntime({
db: {} as never,
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
assertProviderSupportsLoginPty,
});
await expect(
runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() }),
).rejects.toThrow(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
// The runtime re-checked the current capability from the environment id.
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
// The gate failed before the provider lease, so no lease was acquired.
expect(acquireRunLease).not.toHaveBeenCalled();
});
it("acquires the provider lease after the capability check passes", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: "env-1",
driver: "sandbox",
config: { provider: "daytona" },
});
mockEnvironmentService.updateLeaseMetadata.mockResolvedValue(undefined);
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {} };
const acquireRunLease = vi.fn(async () => ({
lease,
environment: { id: "env-1", driver: "sandbox" },
}));
const assertProviderSupportsLoginPty = vi.fn(async () => {});
const runtime = createProductionLoginSessionRuntime({
db: {} as never,
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
assertProviderSupportsLoginPty,
});
const acquired = await runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() });
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
// The gate passed, so the runtime acquired the provider lease after it.
expect(acquireRunLease).toHaveBeenCalledTimes(1);
expect(acquired.providerLeaseId).toBe("provider-lease-1");
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { resolveDuplexAggregateCeilingBytesFromEnv } from "../duplex-aggregate-ceiling-env.js";
describe("resolveDuplexAggregateCeilingBytesFromEnv", () => {
it("uses the safe default and reports nothing when the variable is absent", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(undefined, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("treats a whitespace-only value as a present invalid override, not as absent", () => {
const rawOverride = " ";
const onRejectedOverride = vi.fn();
// The resolver must continue with the safe default. Startup does not fail.
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(rawOverride, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
// The rejection reporter fires one time. A blank value is now visible as
// invalid instead of silent as absent.
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
// The reporter receives only the parsed number. The raw whitespace string
// never reaches the reporter, so no operator-supplied text can reach a log
// line.
const reportedValue = onRejectedOverride.mock.calls[0]?.[0];
expect(typeof reportedValue).toBe("number");
expect(reportedValue).not.toBe(rawOverride);
expect(Number.isFinite(reportedValue)).toBe(true);
});
it("treats an empty-string value as a present invalid override", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("rejects a present non-numeric value and uses the safe default", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("not-a-number", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("passes a valid override through unchanged", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("1048576", onRejectedOverride);
expect(resolved).toBe(1048576);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("accepts a valid override that carries surrounding whitespace", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(" 1048576 ", onRejectedOverride);
expect(resolved).toBe(1048576);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,11 @@ vi.mock("../services/plugin-environment-driver.js", async (importActual) => ({
}));
import type { EffectiveSandboxCapabilities } from "@paperclipai/adapter-utils/execution-target";
import { adapterExecutionTargetDuplexAggregateByteLedger } from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
DuplexAggregateByteLedger,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
@@ -293,6 +298,50 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
});
});
describe("sandbox execution target aggregate byte ledger stamp", () => {
it("stamps the injected ledger on the target with object identity", async () => {
const ledger = new DuplexAggregateByteLedger({
ceilingBytes: DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
duplexAggregateByteLedger: ledger,
});
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox execution target");
}
// The stamped field and the accessor both return the one injected object, so
// one process-owned ledger reaches the host bridge seam.
expect(target.duplexAggregateByteLedger).toBe(ledger);
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBe(ledger);
});
it("leaves the ledger absent when the host injects none", async () => {
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
});
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox execution target");
}
// A run with no injected ledger keeps the seam null; the accessor never makes
// a fresh ledger.
expect(target.duplexAggregateByteLedger).toBeNull();
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBeNull();
});
});
// Build a sandbox execution target with a fixed capability snapshot and a fake
// environment runtime whose openDuplexChannel is a spy. The helper returns the
// runner and the spy so a test reads the capability-gated member.
@@ -14,6 +14,8 @@
// `sid` or `rid` to prove the host drops a mismatched-pair notification and
// counts a protocol error.
// - `exitCode`: when set, the fixture emits an exit notification after the data.
// - `transportClosed`: when true, the exit notification marks a reason-less
// transport close and carries no exit code.
// - `dataAfterExit`: an array of `{ chunk, sid? }`, same shape as `data`. The
// fixture emits these as data notifications after the exit notification, so a
// test can script a batch where a data frame arrives after the exit in the
@@ -21,6 +23,18 @@
// and never lets the exit crowd a data frame out of the pre-open hold.
// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as
// one data notification for the bound session.
// - `writeReplyDelayMs`: when a positive number, the fixture delays each
// `duplexChannelWrite` reply by that many milliseconds, so a test proves the
// host holds the pending-write reservation until the RPC settles.
// - `stopReadingStdinAfterOpen`: when true, the fixture stops reading its stdin
// right after it sends the open reply. The host writes then stay in the host
// stdin write buffer, so a test proves the host meters the transport buffer and
// never releases the transport token on the write-RPC timeout.
// - `exitAfterStopMs`: when a positive number, and the fixture stopped reading
// stdin, the fixture exits after that many milliseconds. The worker exit
// discards the stdin buffer, so a test proves the host releases every held
// transport token on the worker exit. It gives a fast exit for a worker that no
// longer reads its stdin and never sees the shutdown request.
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
// close reply, so a test proves the host retires the worker on an unconfirmed
// close.
@@ -52,11 +66,19 @@ function scriptedFrameLines(directive, hostRouteId, workerSessionId) {
},
})}\n`;
}
if (typeof directive.exitCode === "number") {
if (typeof directive.exitCode === "number" || directive.transportClosed === true) {
const exitParams = {
hostRouteId,
workerSessionId,
exitCode: typeof directive.exitCode === "number" ? directive.exitCode : null,
};
// A transport-close directive marks a reason-less transport close, so the
// fixture carries the discriminator the same way a real worker does.
if (directive.transportClosed === true) exitParams.transportClosed = true;
lines += `${JSON.stringify({
jsonrpc: "2.0",
method: "duplexChannel.exit",
params: { hostRouteId, workerSessionId, exitCode: directive.exitCode },
params: exitParams,
})}\n`;
}
const dataAfterExit = Array.isArray(directive.dataAfterExit) ? directive.dataAfterExit : [];
@@ -124,6 +146,8 @@ rl.on("line", (line) => {
closeMode,
echoInput: directive.echoInput === true,
noWriteReply: mode === "no-write-reply",
writeReplyDelayMs:
typeof directive.writeReplyDelayMs === "number" ? directive.writeReplyDelayMs : 0,
emitAfterCloseChunk:
typeof directive.emitAfterCloseChunk === "string" ? directive.emitAfterCloseChunk : null,
});
@@ -160,6 +184,20 @@ rl.on("line", (line) => {
reply();
}
if (directive.stopReadingStdinAfterOpen === true) {
// Stop reading stdin, so every later host write stays in the host stdin
// write buffer. The host meters the transport buffer and holds the transport
// token until the stream flush, the stream error, or the worker exit.
rl.pause();
process.stdin.pause();
if (typeof directive.exitAfterStopMs === "number" && directive.exitAfterStopMs >= 0) {
// Exit after the delay, so a test observes the worker exit release the held
// transport tokens without a slow shutdown drain on an unread stdin.
setTimeout(() => process.exit(0), directive.exitAfterStopMs);
}
return;
}
// Emit the scripted data and the exit after the open reply, so the host
// binds the route first. Each frame echoes the exact pair; a test overrides
// `sid` or `rid` to force a mismatch.
@@ -195,7 +233,14 @@ rl.on("line", (line) => {
},
});
}
send({ jsonrpc: "2.0", id: message.id, result: null });
const replyWrite = () => send({ jsonrpc: "2.0", id: message.id, result: null });
if (entry.writeReplyDelayMs > 0) {
// Delay the write reply, so the host holds the pending-write reservation for
// a measurable time before the RPC settles.
setTimeout(replyWrite, entry.writeReplyDelayMs);
} else {
replyWrite();
}
return;
}
@@ -50,17 +50,17 @@ rl.on("line", (line) => {
result: {
ok: true,
supportedMethods: [
"setupTokenPtyOpen",
"setupTokenPtyInput",
"setupTokenPtyStop",
"setupTokenPtyClose",
"loginPtyOpen",
"loginPtyInput",
"loginPtyStop",
"loginPtyClose",
],
},
});
return;
}
if (method === "setupTokenPtyOpen") {
if (method === "loginPtyOpen") {
const directive = parseDirective(params.providerLeaseId);
const mode = directive.mode ?? "normal";
const workerSessionId = directive.workerSessionId ?? "ws-1";
@@ -92,7 +92,7 @@ rl.on("line", (line) => {
for (const entry of outputs) {
send({
jsonrpc: "2.0",
method: "setupTokenPty.output",
method: "loginPty.output",
params: {
workerSessionId: entry.sid ?? workerSessionId,
chunk: entry.chunk,
@@ -102,7 +102,7 @@ rl.on("line", (line) => {
if (typeof directive.exitCode === "number") {
send({
jsonrpc: "2.0",
method: "setupTokenPty.exit",
method: "loginPty.exit",
params: { workerSessionId, exitCode: directive.exitCode },
});
}
@@ -110,14 +110,14 @@ rl.on("line", (line) => {
return;
}
if (method === "setupTokenPtyInput") {
if (method === "loginPtyInput") {
// Echo the input back as one output notification for the bound session, so a
// test proves the input reaches the worker and the output routes back.
for (const entry of routes.values()) {
if (entry.workerSessionId === params.workerSessionId) {
send({
jsonrpc: "2.0",
method: "setupTokenPty.output",
method: "loginPty.output",
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
});
}
@@ -126,12 +126,12 @@ rl.on("line", (line) => {
return;
}
if (method === "setupTokenPtyStop") {
if (method === "loginPtyStop") {
send({ jsonrpc: "2.0", id: message.id, result: null });
return;
}
if (method === "setupTokenPtyClose") {
if (method === "loginPtyClose") {
const entry = routes.get(params.hostRouteId);
routes.delete(params.hostRouteId);
const closeMode = entry ? entry.closeMode : "ack";
@@ -72,7 +72,10 @@ import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
import { runningProcesses } from "../adapters/index.ts";
import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts";
import { buildIssueBlockersResolvedWakeStateKey } from "../services/issue-dependency-wakeups.ts";
import {
buildIssueBlockersResolvedWakeStateKey,
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
} from "../services/issue-dependency-wakeups.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -812,6 +815,101 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(stateKeyWakes).toHaveLength(1);
});
it("heals a blocked dependent after a terminal reset when a previous-cycle old-key wake exists", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const previousCycleWakeAt = new Date("2026-07-01T12:00:00.000Z");
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
await db
.update(issues)
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
.where(eq(issues.id, blockedIssueId));
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIssueId,
blockerIssueIds: [blockerIssueId],
},
status: "completed",
finishedAt: previousCycleWakeAt,
requestedAt: previousCycleWakeAt,
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
expect(result.dependencyWakeExistingSkipped).toBe(0);
const cycleKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt,
});
const healedWake = await db
.select({ status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)))
.then((rows) => rows[0] ?? null);
expect(healedWake).not.toBeNull();
expect(["queued", "claimed", "completed"]).toContain(healedWake?.status);
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(secondPass.dependencyWakesHealed).toBe(0);
const cycleKeyWakes = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)));
expect(cycleKeyWakes).toHaveLength(1);
});
it("does not re-heal when a completed old-key wake is from the current blocked cycle", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
const sameCycleWakeAt = new Date("2026-08-01T12:00:01.000Z");
await db
.update(issues)
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
.where(eq(issues.id, blockedIssueId));
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIssueId,
blockerIssueIds: [blockerIssueId],
},
status: "completed",
finishedAt: sameCycleWakeAt,
requestedAt: sameCycleWakeAt,
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(0);
expect(result.dependencyWakeExistingSkipped).toBe(1);
});
it("counts null dependency wake returns as deferred instead of enqueue failures", async () => {
await enableAutoRecovery();
const { companyId, agentId } =
@@ -1,6 +1,9 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildIssueBlockersResolvedWakeStateKey,
} from "../services/issue-dependency-wakeups.ts";
// The first test in this suite imports the large `routes/issues.ts` module
// through `vi.importActual` inside `createApp`. `vi.resetModules()` in
@@ -11,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// default 5000ms test timeout and produce a flaky "Test timed out in 5000ms"
// failure. Give the suite generous headroom, far above the observed cold-start
// yet still below the 30s hook timeout.
vi.setConfig({ testTimeout: 15000 });
vi.setConfig({ testTimeout: 30000 });
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockFindExistingIssueBlockersResolvedWakeForReadyState = vi.hoisted(() => vi.fn(async () => null));
@@ -402,4 +405,280 @@ describe("issue dependency wakeups in issue routes", () => {
);
});
});
function issueRecord(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-100",
title: "Issue",
description: null,
status: "todo",
priority: "medium",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
blockedTransitionAt: null,
labels: [],
labelIds: [],
...overrides,
};
}
it("wakes a Release-like dependent after a terminal reset using the current blocked cycle", async () => {
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
const releaseIssueId = "22222222-2222-4222-8222-222222222222";
const releaseBlockedAt = new Date("2026-08-01T15:00:00.000Z");
mockIssueService.getById.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "in_progress",
}));
mockIssueService.update.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "done",
}));
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: releaseIssueId,
assigneeAgentId: "agent-release",
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: releaseBlockedAt,
},
]);
const res = await request(await createApp()).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockFindExistingIssueBlockersResolvedWakeForReadyState).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
dependentIssueId: releaseIssueId,
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: releaseBlockedAt,
}),
);
expect(mockWakeup).toHaveBeenCalledTimes(1);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-release",
expect.objectContaining({
reason: "issue_blockers_resolved",
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: releaseIssueId,
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: releaseBlockedAt,
}),
payload: expect.objectContaining({
issueId: releaseIssueId,
resolvedBlockerIssueId: reviewIssueId,
mutation: "blocker_done",
}),
}),
);
});
});
it("does not enqueue a second wake when the blocker is already done", async () => {
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
mockIssueService.getById.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "done",
}));
mockIssueService.update.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "done",
}));
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: "22222222-2222-4222-8222-222222222222",
assigneeAgentId: "agent-release",
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: new Date("2026-08-01T15:00:00.000Z"),
},
]);
const res = await request(await createApp()).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" });
expect(res.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockWakeup).not.toHaveBeenCalled();
expect(mockIssueService.listWakeableBlockedDependents).not.toHaveBeenCalled();
});
it("wakes a QA-like chain one dependent at a time after each blocker completes", async () => {
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
const releaseIssueId = "22222222-2222-4222-8222-222222222222";
const qaIssueId = "33333333-3333-4333-8333-333333333333";
const releaseBlockedAt = new Date("2026-08-02T10:00:00.000Z");
const qaBlockedAt = new Date("2026-08-02T10:05:00.000Z");
const app = await createApp();
mockIssueService.getById.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "in_progress",
}));
mockIssueService.update.mockResolvedValue(issueRecord({
id: reviewIssueId,
identifier: "PAP-REVIEW",
title: "Review",
status: "done",
}));
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: releaseIssueId,
assigneeAgentId: "agent-release",
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: releaseBlockedAt,
},
]);
expect((await request(app).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" })).status).toBe(200);
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledTimes(1);
});
expect(mockWakeup).toHaveBeenCalledWith(
"agent-release",
expect.objectContaining({
payload: expect.objectContaining({ issueId: releaseIssueId }),
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: releaseIssueId,
blockerIssueIds: [reviewIssueId],
blockedTransitionAt: releaseBlockedAt,
}),
}),
);
mockWakeup.mockClear();
mockFindExistingIssueBlockersResolvedWakeForReadyState.mockClear();
mockIssueService.getById.mockResolvedValue(issueRecord({
id: releaseIssueId,
identifier: "PAP-RELEASE",
title: "Release",
status: "blocked",
assigneeAgentId: "agent-release",
blockedTransitionAt: releaseBlockedAt,
}));
mockIssueService.update.mockResolvedValue(issueRecord({
id: releaseIssueId,
identifier: "PAP-RELEASE",
title: "Release",
status: "done",
assigneeAgentId: "agent-release",
}));
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: qaIssueId,
assigneeAgentId: "agent-qa",
blockerIssueIds: [releaseIssueId],
blockedTransitionAt: qaBlockedAt,
},
]);
expect((await request(app).patch(`/api/issues/${releaseIssueId}`).send({ status: "done" })).status).toBe(200);
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledTimes(1);
});
expect(mockWakeup).toHaveBeenCalledWith(
"agent-qa",
expect.objectContaining({
payload: expect.objectContaining({
issueId: qaIssueId,
resolvedBlockerIssueId: releaseIssueId,
}),
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: qaIssueId,
blockerIssueIds: [releaseIssueId],
blockedTransitionAt: qaBlockedAt,
}),
}),
);
expect(mockWakeup).not.toHaveBeenCalledWith("agent-release", expect.anything());
});
it("restores a blocked-and-ready dependent under the new blocked cycle key", async () => {
const parentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const childIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const blockedTransitionAt = new Date("2026-08-03T18:00:00.000Z");
mockIssueService.getById.mockResolvedValue(issueRecord({
id: parentIssueId,
identifier: "PAP-200",
title: "Blocked after completion",
status: "done",
assigneeAgentId: "agent-2",
}));
mockIssueService.update.mockResolvedValue(issueRecord({
id: parentIssueId,
identifier: "PAP-200",
title: "Blocked after completion",
status: "blocked",
assigneeAgentId: "agent-2",
blockedTransitionAt,
}));
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: parentIssueId,
blockerIssueIds: [childIssueId],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
pendingFinalizeBlockerIssueIds: [],
allBlockersDone: true,
isDependencyReady: true,
});
const res = await request(await createApp())
.patch(`/api/issues/${parentIssueId}`)
.send({
status: "blocked",
blockedByIssueIds: [childIssueId],
unblockDescriptor: { owner: "board", action: "Review the restored dependency" },
});
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockFindExistingIssueBlockersResolvedWakeForReadyState).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
dependentIssueId: parentIssueId,
blockerIssueIds: [childIssueId],
blockedTransitionAt,
}),
);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: parentIssueId,
blockerIssueIds: [childIssueId],
blockedTransitionAt,
}),
payload: expect.objectContaining({
mutation: "blocked_dependency_restored",
}),
}),
);
});
});
it("does not emit a dependency wake when an unresolved or cancelled blocker remains", async () => {
mockIssueService.getById.mockResolvedValue(issueRecord({ status: "in_progress" }));
mockIssueService.update.mockResolvedValue(issueRecord({ status: "done" }));
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
expect(res.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockWakeup).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,348 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import {
createDuplexRouteSlotController,
createPluginWorkerHandle,
} from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges every retained duplex route
// representation against the injected aggregate byte ledger, and releases each
// token exactly once through the one cleanup path. The tests drive a real worker
// fixture process, so they exercise the true frame order across the bind boundary.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A counting telemetry surface. It records how many times the ledger rejected a
// reservation and how many accounting defects it saw, so a test asserts a
// fail-closed rejection and proves the cleanup never double-releases.
function countingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
rejections: number;
underflows: number;
} {
const state = {
rejections: 0,
underflows: 0,
setBytesInUse() {},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
describe("plugin worker manager duplex aggregate byte ledger", () => {
it("keeps terminalized buffered bytes charged until a late listener drains them, then a worker exit is harmless", async () => {
const telemetry = countingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
// Lower the per-chunk char bound, so the second chunk ends the route while the
// first chunk stays buffered.
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexChannelLimits: { maxChunkChars: 4 },
});
try {
await handle.start();
const session = await handle.openDuplexChannel(
// No listener attaches, so "ok" buffers and charges its two raw bytes. The
// "toolong" chunk passes the per-chunk bound, so the route terminalizes and
// moves the still-buffered "ok" token to the terminal registry.
duplexOpenInput({ data: [{ chunk: "ok" }, { chunk: "toolong" }] }),
);
// The buffered "ok" stays charged after the route leaves the live map.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(2);
expect(ledger.liveTokenCount).toBe(1);
});
// A late listener drains the terminal buffered record and releases its token.
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await vi.waitFor(() => {
expect(chunks).toEqual(["ok"]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
// The over-bound chunk retained nothing, so no reservation ever rejected.
expect(telemetry.rejections).toBe(0);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit sweep runs on stop. The ledger stays at zero, and the sweep
// records no accounting defect, so the drain and the exit never double-release.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("transfers pre-bind held bytes to the buffered representation across the bind, then releases them on drain", async () => {
const telemetry = countingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const session = await handle.openDuplexChannel(
// The worker writes the open reply and the two data frames in one stdout
// write, so both frames arrive before the route binds. The host holds them
// as pre-bind events, then the bind replays and transfers each token to the
// buffered representation.
duplexOpenInput({ batchWithOpenReply: true, data: [{ chunk: "aa" }, { chunk: "bb" }] }),
);
// The two held events keep their exact reserved bytes across the transfer, so
// the live-token count never grows and the gauge never re-admits.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(4);
expect(ledger.liveTokenCount).toBe(2);
});
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await vi.waitFor(() => {
expect(chunks).toEqual(["aa", "bb"]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("fails closed and retains nothing when a buffered reservation would pass the ceiling", async () => {
const telemetry = countingTelemetry();
// A four-byte ceiling. One five-byte chunk cannot fit.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 4, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
await handle.openDuplexChannel(
// No listener attaches, so "hello" tries to buffer. Its five raw bytes pass
// the four-byte ceiling, so the reservation rejects and the route ends.
duplexOpenInput({ data: [{ chunk: "hello" }] }),
);
await vi.waitFor(() => {
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
});
// A telemetry surface that also tracks the peak gauge value. A test asserts the
// peak never passes the ceiling, so it proves aggregate admission stops at the
// ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// The exact raw byte count of one buffered chunk each route retains.
const LOAD_CHUNK_BYTES = 8;
const LOAD_CHUNK = "x".repeat(LOAD_CHUNK_BYTES);
describe("plugin worker manager duplex aggregate byte ledger load across workers", () => {
it("stops aggregate byte admission at the ceiling across many routes and workers while the route-count controller stays open", async () => {
const telemetry = peakTrackingTelemetry();
// The ceiling holds exactly six eight-byte buffered chunks.
const fittingRoutes = 6;
const ceilingBytes = fittingRoutes * LOAD_CHUNK_BYTES;
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
// The route-count controller has ample room, so the byte ledger binds first.
const routeSlots = createDuplexRouteSlotController(100);
const handleCount = 3;
const routesPerHandle = 3;
const totalRoutes = handleCount * routesPerHandle;
const rejectedRoutes = totalRoutes - fittingRoutes;
const handles: Array<ReturnType<typeof makeDuplexHandle>> = [];
try {
for (let h = 0; h < handleCount; h += 1) {
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
await handle.start();
handles.push(handle);
}
// Open every route in order. Each worker batches one data chunk with the open
// reply, so the host holds the chunk as a pre-bind event and reserves its exact
// raw bytes before the route binds. No listener attaches, so a bound route holds
// the chunk. The first six chunks fit the ceiling. Each later reservation passes
// the ceiling, so the ledger rejects it and the route fails closed with the open
// marker (not the route-busy marker), because the route-count controller has room.
let opened = 0;
let failedClosed = 0;
for (let h = 0; h < handleCount; h += 1) {
for (let r = 0; r < routesPerHandle; r += 1) {
try {
await handles[h].openDuplexChannel(
duplexOpenInput({
workerSessionId: `ws-${h}-${r}`,
batchWithOpenReply: true,
data: [{ chunk: LOAD_CHUNK }],
}),
);
opened += 1;
} catch (err) {
// A byte-ceiling rejection fails the route closed with the open marker.
// The route-count controller had room, so this is never the route-busy
// marker.
expect(String(err)).toContain("DUPLEX_CHANNEL_OPEN_FAILED");
expect(String(err)).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
failedClosed += 1;
}
}
}
// Six routes bound and hold their bytes; three failed closed at the ceiling.
expect(opened).toBe(fittingRoutes);
expect(failedClosed).toBe(rejectedRoutes);
expect(opened + failedClosed).toBe(totalRoutes);
// The aggregate gauge holds at the ceiling and the ledger recorded one rejection
// per over-ceiling route.
await vi.waitFor(() => {
expect(telemetry.rejections).toBe(rejectedRoutes);
expect(ledger.bytesInUse).toBe(ceilingBytes);
expect(ledger.liveTokenCount).toBe(fittingRoutes);
});
// The gauge never passed the ceiling, so admission stopped at the ceiling and
// never overshot it.
expect(telemetry.peak).toBe(ceilingBytes);
} finally {
for (const handle of handles) await handle.stop().catch(() => undefined);
}
// Every worker exit sweep released its buffered tokens once, so the ledger ends
// at zero with no accounting defect.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("still bounds the route count for fixed per-route overhead when the byte ceiling has ample room", async () => {
const telemetry = peakTrackingTelemetry();
// A large byte ceiling, so retained bytes never bind admission.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
// The route-count controller holds two slots for the whole process.
const routeSlots = createDuplexRouteSlotController(2);
const handleA = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
const handleB = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
let opened = 0;
let busy = 0;
try {
await handleA.start();
await handleB.start();
// Attempt four routes across the two workers. The shared controller admits the
// first two and rejects the last two with the fixed route-busy error.
const attempts: Array<[ReturnType<typeof makeDuplexHandle>, string]> = [
[handleA, "ws-a1"],
[handleA, "ws-a2"],
[handleB, "ws-b1"],
[handleB, "ws-b2"],
];
for (const [handle, ws] of attempts) {
try {
await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: ws, data: [{ chunk: "aa" }] }),
);
opened += 1;
} catch (err) {
expect(String(err)).toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
busy += 1;
}
}
// The route-count controller admitted two routes and rejected the rest, so it
// still bounds the fixed per-route overhead on its own.
expect(opened).toBe(2);
expect(busy).toBe(2);
// The two open routes buffered four raw bytes, far under the byte ceiling, and
// the byte ledger rejected nothing.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(4);
expect(ledger.liveTokenCount).toBe(2);
});
expect(telemetry.rejections).toBe(0);
} finally {
await handleA.stop().catch(() => undefined);
await handleB.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
});
@@ -0,0 +1,251 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
// Mock the shared logger, so a test reads the fixed rejection marker the manager
// logs when a pending-write reservation fails. The child logger returns the same
// object, so `log.warn` is this mock's `warn`.
vi.mock("../middleware/logger.js", () => {
const mockLogger: Record<string, unknown> = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
};
return { logger: mockLogger, httpLogger: vi.fn() };
});
import { logger } from "../middleware/logger.js";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges every host→worker write raw
// payload against the injected aggregate byte ledger under the `pending_write`
// owner, and releases each token one time when the write RPC settles. The child
// reads its stdin here, so the separate `stdin_write` transport token flushes and
// releases at once. Each test waits for that flush, so the assertions isolate the
// raw-payload token. The transport token has its own suite.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A telemetry surface that tracks the peak gauge value and the two counters. A
// test asserts the peak never passes the ceiling, so it proves aggregate admission
// stops at the ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// Return every reason string the manager logged through the mock `warn` sink.
function loggedWarnReasons(): string[] {
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
return calls
.map((call) => {
const first = call[0];
return first && typeof first === "object"
? (first as { reason?: unknown }).reason
: undefined;
})
.filter((reason): reason is string => typeof reason === "string");
}
describe("plugin worker manager duplex pending-write byte ledger", () => {
it("charges each held raw payload and returns to zero after worker exit", async () => {
const telemetry = peakTrackingTelemetry();
// The ceiling has ample room, so a transient transport reservation never
// rejects. Each write holds its raw payload until the RPC settles.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
const writeBytes = 1000;
const writeData = "x".repeat(writeBytes);
try {
await handle.start();
// The worker reads its stdin but never replies to a write, so each write RPC
// stays pending and holds its raw payload.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-a", mode: "no-write-reply" }),
);
route.write(writeData);
route.write(writeData);
route.write(writeData);
// The worker reads its stdin, so each transport token flushes and releases.
// Only the three held raw payloads remain, so the gauge settles at three
// times the payload byte count with three live tokens.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(3);
expect(ledger.bytesInUse).toBe(3 * writeBytes);
});
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit settles each pending write, so each token releases one time
// and the ledger ends at zero with no accounting defect.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("releases the raw-payload token after a delayed write RPC settles, with no worker exit", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
// The worker reads its stdin and delays its write reply, so the host holds the
// raw-payload reservation for a measurable time before the RPC settles.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-d", writeReplyDelayMs: 150 }),
);
const writeBytes = 1000;
route.write("x".repeat(writeBytes));
// The transport token flushes at once, so one raw-payload token stays held for
// the full write byte count while the RPC is in flight.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBe(writeBytes);
});
// The delayed reply settles the RPC, and the `finally` releases the token.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("reserves the exact UTF-8 byte count of the raw payload, not the character length", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-u", mode: "no-write-reply" }),
);
// "a€" is two characters but four UTF-8 bytes (one plus three). The raw-payload
// reservation must charge four bytes, so it uses the UTF-8 byte count.
const data = "a€";
expect(data.length).toBe(2);
expect(Buffer.byteLength(data, "utf8")).toBe(4);
route.write(data);
// The transport token flushes, so one raw-payload token of four bytes remains.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBe(4);
});
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("ends an over-ceiling single write fail-closed with the aggregate marker, not the route-busy marker", async () => {
vi.mocked(logger.warn).mockClear();
const telemetry = peakTrackingTelemetry();
// The ceiling is smaller than one raw payload, so the raw-payload reservation
// fails before the frame reaches the transport. The manager retains nothing.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-o", mode: "no-write-reply" }),
);
// The single write is larger than the ceiling. The raw-payload reservation
// fails, so the manager ends the route with the aggregate marker, never the
// route-busy marker, and never writes the frame.
route.write("x".repeat(200));
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
const reasons = loggedWarnReasons();
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
});
@@ -0,0 +1,297 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
// Mock the shared logger, so a test reads the fixed rejection marker the manager
// logs when a transport reservation fails. The child logger returns the same
// object, so `log.warn` is this mock's `warn`.
vi.mock("../middleware/logger.js", () => {
const mockLogger: Record<string, unknown> = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
};
return { logger: mockLogger, httpLogger: vi.fn() };
});
import { logger } from "../middleware/logger.js";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges the child-stdin transport
// buffer for every host→worker duplex write, under the `stdin_write` owner. The
// worker completes the open, then stops reading its stdin. The host write then
// stays in the host stdin write buffer. The tests prove the reservation covers the
// serialized frame, holds until the flush or the worker exit, survives the write
// RPC timeout and the route terminalization, and bounds the aggregate transport
// bytes across several routes and workers.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A telemetry surface that tracks the peak gauge value and the two counters. A
// test asserts the peak never passes the ceiling, so it proves aggregate admission
// stops at the ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// Return every reason string the manager logged through the mock `warn` sink.
function loggedWarnReasons(): string[] {
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
return calls
.map((call) => {
const first = call[0];
return first && typeof first === "object"
? (first as { reason?: unknown }).reason
: undefined;
})
.filter((reason): reason is string => typeof reason === "string");
}
// A frame the host keeps in its stdin write buffer must pass the operating-system
// pipe buffer (about 64 KiB), so the write callback stays pending while the child
// does not read. Each write here is larger than that, so the host holds the
// transport token until the worker exit.
const HELD_WRITE_CHARS = 200_000;
describe("plugin worker manager duplex stdin transport byte ledger", () => {
it("reserves the encoded serialized-frame size, larger than the raw payload, including JSON escaping", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-a",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 500,
}),
);
// Every character is a quote. The JSON serialization escapes each quote to two
// bytes, so the serialized frame is at least twice the raw payload byte count.
const data = '"'.repeat(50_000);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
// The raw-payload token and the transport token both hold at once. The worker
// does not read its stdin, so the transport token never flushes.
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
// The transport reservation covers the serialized frame, so it is larger than
// the raw payload and it includes the doubled escaped quotes.
expect(transportBytes).toBeGreaterThan(rawBytes);
expect(transportBytes).toBeGreaterThanOrEqual(2 * rawBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit discards the stdin buffer, so every token releases one time.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("holds the transport token across a write-RPC timeout and releases it on worker exit", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
// The write RPC times out fast; the worker exits later. The gap lets a test
// prove the timeout does not release the transport token.
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexChannelLimits: { openTimeoutMs: 150 },
});
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-t",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
const data = "x".repeat(HELD_WRITE_CHARS);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
// Both tokens hold at first: the raw payload and the serialized frame.
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
expect(transportBytes).toBeGreaterThan(0);
// The write RPC times out. The RPC settle releases the raw-payload token, so
// one token remains: the transport token. The timeout never releases it.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
});
expect(ledger.bytesInUse).toBe(transportBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit releases the still-held transport token.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("does not release the transport token when the route terminalizes", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-c",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 700,
}),
);
const data = "x".repeat(HELD_WRITE_CHARS);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
// End the route. Route terminalization releases the route-owned tokens, but it
// must never release the transport token, so at least the serialized frame
// stays charged.
void route.close();
expect(ledger.bytesInUse).toBeGreaterThanOrEqual(transportBytes);
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("bounds the aggregate transport bytes across several routes and workers, ending an over-ceiling near-limit write with the aggregate marker", async () => {
vi.mocked(logger.warn).mockClear();
const telemetry = peakTrackingTelemetry();
// The ceiling admits only a couple of held serialized frames, so a near-limit
// write across the routes passes it and rejects.
const ceilingBytes = 600_000;
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
const handleA = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
const handleB = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handleA.start();
await handleB.start();
const routeA = await handleA.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-a",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
const routeB = await handleB.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-b",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
// Each write mixes escaped quotes and multi-byte characters near the per-write
// size. The serialized frame escapes each quote and keeps each euro sign as
// three UTF-8 bytes, so the reservation must count the encoded size.
const escaped = '"'.repeat(40_000);
const multiByte = "€".repeat(20_000);
const nearLimit = escaped + multiByte;
const routes = [routeA, routeB, routeA, routeB];
for (const route of routes) {
route.write(nearLimit);
// The aggregate bytes never pass the ceiling. Admission stops at the ceiling.
expect(ledger.bytesInUse).toBeLessThanOrEqual(ceilingBytes);
}
// At least one near-limit write passed the ceiling and rejected. The manager
// ended that route with the aggregate marker, never the route-busy marker.
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(telemetry.peak).toBeLessThanOrEqual(ceilingBytes);
const reasons = loggedWarnReasons();
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
} finally {
await handleA.stop().catch(() => undefined);
await handleB.stop().catch(() => undefined);
}
// Every worker exit releases its held transport tokens, so the ledger ends at
// zero with no accounting defect.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
});
@@ -163,6 +163,30 @@ describe("plugin worker manager duplex channel route", () => {
}
});
it("carries a transport-close exit through to the wait result", async () => {
const handle = makeDuplexHandle();
try {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-A",
data: [{ chunk: "one" }],
// The worker reports a reason-less transport close with no exit code.
transportClosed: true,
}),
);
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
// The discriminator survives the worker exit notification, so the host wait
// resolves with the transport-close mark and no exit code.
await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true });
expect(chunks).toEqual(["one"]);
await session.close();
} finally {
await handle.stop().catch(() => undefined);
}
});
it("isolates a throwing listener during the buffered replay so every buffered chunk routes", async () => {
const handle = makeDuplexHandle();
try {
@@ -9,7 +9,6 @@ import {
type HostServices,
type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk";
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
import {
appendStderrExcerpt,
createPluginWorkerHandle,
@@ -1060,14 +1059,14 @@ describe("plugin worker manager execute.log route", () => {
// Host-owned setup-token login pseudo-terminal route gate
// ---------------------------------------------------------------------------
const SETUP_TOKEN_PTY_WORKER_ENTRYPOINT = path.join(
const LOGIN_PTY_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-setup-token-pty.cjs",
"plugin-worker-login-pty.cjs",
);
function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
function makeLoginPtyHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: SETUP_TOKEN_PTY_WORKER_ENTRYPOINT,
entrypointPath: LOGIN_PTY_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
@@ -1077,40 +1076,72 @@ function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
});
}
// One valid session home. The shape is the fixed root, one slash, and one UUID.
const PTY_SESSION_HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555";
function ptyOpenInput(directive: unknown) {
return {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged. The manager allowlists `command`, so the
// command stays the fixed `CLAUDE_SETUP_TOKEN_COMMAND` for every route-gate
// case.
// forwards to the worker unchanged. The manager carries the closed command
// key and the validated session home; it carries no command string.
providerLeaseId: JSON.stringify(directive),
command: CLAUDE_SETUP_TOKEN_COMMAND,
loginCommandKey: "claude" as const,
sessionHome: PTY_SESSION_HOME,
};
}
describe("plugin worker manager setup-token pty route gate", () => {
it("rejects a command that is not the allowlisted setup-token command before the worker call", async () => {
const handle = makeSetupTokenPtyHandle();
it("rejects a command key that is not in the closed set before the worker call", async () => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
// A caller passes a command other than the fixed `CLAUDE_SETUP_TOKEN_COMMAND`.
// The manager rejects it with one fixed non-secret error before the worker
// call, so no arbitrary process spawns in the sandbox pseudo-terminal.
// The provider driver key routes the worker; it confers no command
// authority. A key outside the closed set rejects with one fixed non-secret
// error before the worker call, so a caller cannot select an arbitrary
// command in the sandbox pseudo-terminal.
await expect(
handle.openSetupTokenPtySession({
handle.openLoginPtySession({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: JSON.stringify({ mode: "normal" }),
command: "rm -rf /",
loginCommandKey: "gemini" as unknown as "claude",
sessionHome: PTY_SESSION_HOME,
}),
).rejects.toThrow("SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED");
// The rejected open never consumed the single route, so a later open with
// the allowlisted command still succeeds.
const session = await handle.openSetupTokenPtySession(
).rejects.toThrow("LOGIN_PTY_COMMAND_NOT_ALLOWED");
// The rejected open never consumed the single route, so a later open with a
// closed key still succeeds.
const session = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
expect(session).toBeDefined();
await session.close();
} finally {
await handle.stop().catch(() => undefined);
}
});
it("rejects a malformed session home before the worker call", async () => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
// The host revalidates the server-controlled session home shape before the
// worker RPC. A traversal candidate fails closed at the last host gate.
await expect(
handle.openLoginPtySession({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: JSON.stringify({ mode: "normal" }),
loginCommandKey: "claude" as const,
sessionHome: "/tmp/paperclip-adapter-login/../etc",
}),
).rejects.toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
// The rejected open never consumed the single route.
const session = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
expect(session).toBeDefined();
@@ -1121,21 +1152,21 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("permits one active credential pseudo-terminal per worker", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
const first = await handle.openSetupTokenPtySession(
const first = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
// A second open while the first route is not closed rejects with one fixed
// non-secret error before it reaches the worker.
await expect(
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
).rejects.toThrow("SETUP_TOKEN_PTY_ROUTE_BUSY");
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
).rejects.toThrow("LOGIN_PTY_ROUTE_BUSY");
await first.close();
// After the first route closes and the worker acknowledges the close, a new
// open is admitted.
const second = await handle.openSetupTokenPtySession(
const second = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
await second.close();
@@ -1145,10 +1176,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("delivers output only for the exact bound worker session id and drops a mismatch", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({
workerSessionId: "ws-A",
outputs: [
@@ -1172,10 +1203,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("routes delayed input to the worker and back to the listener", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({ workerSessionId: "ws-A" }),
);
const chunks: string[] = [];
@@ -1191,12 +1222,12 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
const handle = makeSetupTokenPtyHandle({
setupTokenPtyLimits: { maxTotalChars: 10 },
const handle = makeLoginPtyHandle({
loginPtyLimits: { maxTotalChars: 10 },
});
try {
await handle.start();
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({
outputs: [
{ chunk: "aaaaa" }, // total 5 → delivered
@@ -1217,15 +1248,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
await expect(
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "malformed-open" })),
).rejects.toThrow("SETUP_TOKEN_PTY_OPEN_FAILED");
handle.openLoginPtySession(ptyOpenInput({ mode: "malformed-open" })),
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
// The terminalize closed the route by the host route id and the worker
// acknowledged the close, so a later open is admitted.
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
expect(session).toBeDefined();
@@ -1236,13 +1267,13 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("terminalizes the route on an open timeout", async () => {
const handle = makeSetupTokenPtyHandle({
setupTokenPtyLimits: { openTimeoutMs: 200 },
const handle = makeLoginPtyHandle({
loginPtyLimits: { openTimeoutMs: 200 },
});
try {
await handle.start();
await expect(
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "no-open-reply" })),
handle.openLoginPtySession(ptyOpenInput({ mode: "no-open-reply" })),
).rejects.toThrow();
} finally {
await handle.stop().catch(() => undefined);
@@ -1250,10 +1281,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({
mode: "duplicate-open-reply",
workerSessionId: "ws-A",
@@ -1274,10 +1305,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("closes the route with a fixed exit when the worker exits", async () => {
const handle = makeSetupTokenPtyHandle();
const handle = makeLoginPtyHandle();
try {
await handle.start();
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal" }),
);
const waitResult = session.wait();
@@ -1291,15 +1322,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
});
it("retires the worker on an unconfirmed close acknowledgement", async () => {
const handle = makeSetupTokenPtyHandle({
setupTokenPtyLimits: { closeTimeoutMs: 200 },
const handle = makeLoginPtyHandle({
loginPtyLimits: { closeTimeoutMs: 200 },
});
try {
await handle.start();
const exited = new Promise<void>((resolve) => {
handle.on("exit", () => resolve());
});
const session = await handle.openSetupTokenPtySession(
const session = await handle.openLoginPtySession(
ptyOpenInput({ mode: "normal", closeMode: "bad-ack" }),
);
await session.close();
@@ -1307,7 +1338,7 @@ describe("plugin worker manager setup-token pty route gate", () => {
// fails closed and retires the worker before any reuse.
await exited;
await expect(
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
).rejects.toThrow();
} finally {
await handle.stop().catch(() => undefined);
@@ -18,7 +18,6 @@ function makeModule(loginCapability?: unknown) {
const validLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
-2
View File
@@ -26,7 +26,6 @@ describe("built-in adapter login capabilities", () => {
expect(capability).toBeDefined();
if (!capability) return;
expect(capability.panelMode).toBe("displayed_code");
expect(capability.sandboxTransport).toBe("streamed_exec");
expect(capability.timeoutPolicy).toBe("caller_bounded");
expect(capability.completionClaim).toBeUndefined();
expect(typeof capability.getCommand).toBe("function");
@@ -39,7 +38,6 @@ describe("built-in adapter login capabilities", () => {
expect(capability).toBeDefined();
if (!capability) return;
expect(capability.panelMode).toBe("submitted_browser_code");
expect(capability.sandboxTransport).toBe("pseudo_terminal");
expect(capability.timeoutPolicy).toBe("fixed");
expect(capability.completionClaim).toBe("storedSessionId");
expect(typeof capability.getCommand).toBe("function");
+5 -6
View File
@@ -604,7 +604,6 @@ Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly
// values only.
const claudeLoginCapability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND,
parsePrompt: (output) => {
@@ -619,13 +618,13 @@ const claudeLoginCapability: AdapterLoginCapability = {
};
// The Codex interactive login capability. Codex runs `codex login --device-auth`
// over the streamed exec channel. The flow shows a one-time code that the user
// enters in the browser. The caller sets the host-side timeout. The device-login
// flow writes its credential inside the sandbox, so the capability declares no
// terminal credential capture and no completion claim.
// on a real pseudo-terminal, because a pipe emits no login prompt. The flow shows
// a one-time code that the user enters in the browser. The caller sets the
// host-side timeout. The device-login flow writes its credential inside the
// sandbox, so the capability declares no terminal credential capture and no
// completion claim.
const codexLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => CODEX_DEVICE_LOGIN_COMMAND,
parsePrompt: (output) => {
+2 -2
View File
@@ -36,7 +36,7 @@ import {
createProductionSetupTokenSandboxProvider,
createProductionSetupTokenCleanupStore,
createSetupTokenSecretWriter,
createWorkerBoundSetupTokenPtyOpener,
createWorkerBoundLoginPtyOpener,
} from "./services/setup-token-transport-binding.js";
import { environmentService } from "./services/environments.js";
import { environmentRuntimeService } from "./services/environment-runtime.js";
@@ -466,7 +466,7 @@ export async function createApp(
sandbox: createProductionSetupTokenSandboxProvider({
environments: environmentService(db),
environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workerManager }),
openLivePtySession: createWorkerBoundSetupTokenPtyOpener({
openLivePtySession: createWorkerBoundLoginPtyOpener({
workerManager,
environments: environmentService(db),
log: (line) => logger.info(line),
@@ -0,0 +1,32 @@
import {
resolveDuplexAggregateCeilingBytes,
type DuplexAggregateCeilingOverrideRejectionReporter,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
/**
* Resolve the aggregate duplex route byte ceiling from the raw operator override
* string. The host reads the override from
* PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
*
* This helper distinguishes an absent variable from a present blank value. An
* absent variable is `undefined`. The helper then uses the documented default and
* does not report a rejection. A present variable is a string, even when the
* string holds only whitespace. The helper sends every present string through
* `Number`, so a blank or whitespace-only value becomes `0`. The numeric
* validation in {@link resolveDuplexAggregateCeilingBytes} rejects `0`, reports it
* through `onRejectedOverride`, and returns the safe default. So a present blank
* value is visible as invalid instead of silent as absent.
*
* `Number` ignores surrounding whitespace, so a valid value with surrounding
* spaces still parses. The reporter receives only the parsed number, never the raw
* string, so no operator-supplied text reaches a log line.
*/
export function resolveDuplexAggregateCeilingBytesFromEnv(
rawOverride: string | undefined,
onRejectedOverride?: DuplexAggregateCeilingOverrideRejectionReporter,
): number {
return resolveDuplexAggregateCeilingBytes(
rawOverride === undefined ? undefined : Number(rawOverride),
onRejectedOverride,
);
}
+57 -2
View File
@@ -84,6 +84,13 @@ import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-sh
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js";
import { isLoopbackHost, rewriteLoopbackUrlPort } from "./url-utils.js";
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
import {
createDuplexAggregateByteLedgerTelemetry,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerMetricSink,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { resolveDuplexAggregateCeilingBytesFromEnv } from "./duplex-aggregate-ceiling-env.js";
import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL } from "@paperclipai/adapter-utils/duplex-telemetry";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -755,9 +762,57 @@ export async function startServer(): Promise<StartedServer> {
databaseBackupInFlight = false;
}
};
const pluginWorkerManager = createPluginWorkerManager();
// The process-owned aggregate byte ledger for the sandbox duplex channel. One
// ledger per host process bounds the aggregate bytes that all live duplex routes
// retain. The route-count controller bounds only the route count, so without this
// ledger the per-route byte bounds multiply to many gigabytes at the maximum
// route count. The manager injects this same object into every worker handle, so
// one shared gauge bounds every host-side retention site.
//
// The optional operator override reads PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
// An absent variable uses the documented default. A present invalid, blank,
// whitespace-only, non-finite, zero, negative, non-integer, unsafe, or
// over-maximum value does not fail startup. The host process is multi-tenant, so
// one invalid environment value must not brick the whole host. The helper sends
// the raw string to the resolver, so a present blank value is invalid, not
// absent. The resolver rejects the invalid override and returns the safe default.
// The reporter logs the rejection loudly at error, so the misconfiguration stays
// visible while the host stays up. The log line carries only the rejected numeric
// value, never the raw string.
const duplexAggregateCeilingBytes = resolveDuplexAggregateCeilingBytesFromEnv(
process.env.PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
(rejectedValue) => {
logger.error(
{ rejectedValue },
"duplex aggregate byte ceiling override rejected; using the safe default",
);
},
);
// The server has no process metric pipeline yet, so the ledger telemetry maps to
// the structured logger. The gauge logs at debug. A reservation rejection logs at
// warn, because it marks an availability limit hit. An accounting-underflow defect
// logs at error, because it marks a real cleanup bug. Each record carries only the
// fixed metric name and the numeric value; no route, company, run, or payload
// value reaches a log line.
const duplexAggregateByteLedgerMetricSink: DuplexAggregateByteLedgerMetricSink = {
setGauge(name, value) {
logger.debug({ metric: name, value }, "duplex aggregate byte ledger gauge");
},
incrementCounter(name) {
if (name === DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL) {
logger.error({ metric: name }, "duplex aggregate byte ledger accounting underflow");
return;
}
logger.warn({ metric: name }, "duplex aggregate byte ledger reservation rejected");
},
};
const duplexAggregateByteLedger = new DuplexAggregateByteLedger({
ceilingBytes: duplexAggregateCeilingBytes,
telemetry: createDuplexAggregateByteLedgerTelemetry(duplexAggregateByteLedgerMetricSink),
});
const pluginWorkerManager = createPluginWorkerManager({ duplexAggregateByteLedger });
const heartbeat = config.heartbeatSchedulerEnabled
? heartbeatService(db as any, { pluginWorkerManager })
? heartbeatService(db as any, { pluginWorkerManager, duplexAggregateByteLedger })
: null;
const decisionServiceOptions = {
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),
-4
View File
@@ -22,7 +22,6 @@ function makeAdapter(overrides: Partial<ServerAdapterModule> = {}): ServerAdapte
const displayedCodeLogin: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
@@ -33,7 +32,6 @@ describe("buildAdapterCapabilities login projection", () => {
const caps = buildAdapterCapabilities(makeAdapter({ loginCapability: displayedCodeLogin }));
expect(caps.login).toEqual({
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
});
});
@@ -48,7 +46,6 @@ describe("buildAdapterCapabilities login projection", () => {
makeAdapter({
loginCapability: {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => "vendor setup-token",
parsePrompt: () => null,
@@ -59,7 +56,6 @@ describe("buildAdapterCapabilities login projection", () => {
);
expect(caps.login).toEqual({
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
});
expect(caps.login).not.toHaveProperty("getCommand");
+4 -6
View File
@@ -43,7 +43,6 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import type {
AdapterLoginPanelMode,
AdapterLoginSandboxTransport,
AdapterLoginTimeoutPolicy,
} from "@paperclipai/adapter-utils";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
@@ -101,13 +100,13 @@ interface AdapterInstallRequest {
/**
* The safe scalar login fields the adapter listing projects to the client. It
* carries only the panel mode, the sandbox transport, and the timeout policy.
* It carries no function member and no secret. The user interface reads it to
* pick the login flow and the login panel.
* carries only the panel mode and the timeout policy. It carries no function
* member and no secret. The user interface reads it to pick the login flow and
* the login panel. Every login runs on a real pseudo-terminal, so the projection
* carries no transport field.
*/
interface AdapterLoginProjection {
panelMode: AdapterLoginPanelMode;
sandboxTransport: AdapterLoginSandboxTransport;
timeoutPolicy: AdapterLoginTimeoutPolicy;
}
@@ -189,7 +188,6 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC
? {
login: {
panelMode: login.panelMode,
sandboxTransport: login.sandboxTransport,
timeoutPolicy: login.timeoutPolicy,
},
}
+94 -30
View File
@@ -90,6 +90,7 @@ import {
hasCompanyAccess,
} from "./authz.js";
import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js";
import { isLoginCommandSupportedAdapterType } from "../services/login-command.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectAgentAdapterWorkspaceCommandPaths,
@@ -187,8 +188,11 @@ import {
import {
AdapterAuthSessionConflictError,
createCodexDeviceLoginService,
createCodexWorkerBoundLoginPtyOpener,
createDbAdapterAuthSessionStore,
createProductionLoginSessionRuntime,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
} from "../services/codex-device-login-service.js";
import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
@@ -572,7 +576,28 @@ export function agentRoutes(
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
const adapterLoginService = createCodexDeviceLoginService({
store: adapterLoginStore,
runtime: createProductionLoginSessionRuntime({ db, environmentRuntime }),
runtime: createProductionLoginSessionRuntime({
db,
environmentRuntime,
// Re-check the provider login pseudo-terminal capability from current
// runtime state immediately before the provider lease. The route gate ran
// earlier, so a managed reconciliation can rebind the environment to an
// unsupported provider between the gate and the acquire; this fails closed
// before the lease and the pseudo-terminal.
assertProviderSupportsLoginPty: (environmentId) =>
assertCodexLoginProviderCapability(environmentId),
// Wire the live Codex pseudo-terminal opener through the plugin worker
// manager route. The opener sets the sandbox `CODEX_HOME` to the same
// server-controlled session home the descriptor-bound credential read
// opens. When no worker manager is bound, the runtime keeps its fail-closed
// opener and the login fails closed.
openLivePtySession: options.pluginWorkerManager
? createCodexWorkerBoundLoginPtyOpener({
workerManager: options.pluginWorkerManager,
log: (line) => logger.info(line),
})
: undefined,
}),
// The mandatory credential promotion. A successful login authenticates only
// after this promotion validates the exact staged credential, runs an
// independent readiness check, confirms the session still holds the sole
@@ -1405,15 +1430,25 @@ export function agentRoutes(
return findActiveServerAdapter(type)?.loginCapability ?? null;
}
// The device-login route drives a login over the streamed exec channel. It
// serves any adapter whose registry login capability declares that transport.
// The guard reads the capability, not the adapter name, so a new adapter with
// the same transport passes with no code change. It rejects an adapter with no
// matching capability with a fixed 400.
function assertStreamedExecLoginAdapter(type: string): void {
if (
getRegistryLoginCapability(type)?.sandboxTransport !== "streamed_exec"
) {
// The device-login route drives a login that shows a one-time code on a real
// pseudo-terminal. It serves an adapter whose registry login capability
// declares the displayed-code panel mode and whose trusted adapter type maps
// to a login command key. The guard reads the panel mode and the command map,
// not the adapter name, so a new adapter that satisfies both passes with no
// guard code change. It rejects an adapter with no matching capability, and an
// adapter with no mapped command key, with the same fixed 400.
//
// The command-map check keeps admission consistent with the closed command
// map. The login opener resolves the command key from the same map. An adapter
// that declares the displayed-code capability but has no mapped key would pass
// the panel-mode check, then fail at command resolution after the route
// creates session state. The guard rejects it before any session or lease side
// effect.
function assertDeviceLoginAdapter(type: string): void {
if (getRegistryLoginCapability(type)?.panelMode !== "displayed_code") {
throw badRequest(`Adapter "${type}" does not support a device login.`);
}
if (!isLoginCommandSupportedAdapterType(type)) {
throw badRequest(`Adapter "${type}" does not support a device login.`);
}
}
@@ -1469,16 +1504,15 @@ export function agentRoutes(
}
/**
* Fails closed when the environment provider does not advertise the Claude
* setup-token login capability. It resolves the effective provider from the
* environment config, then reads the static capability from the provider
* plugin manifest. It never checks the provider by name. A missing plugin, a
* non-plugin provider, and a provider without the flag all fail closed with
* the fixed, typed error, so no session row, lease, or pseudo-terminal starts.
* Reports whether the environment provider advertises the login pseudo-terminal
* capability. It resolves the effective provider from the current environment
* config, then reads the static capability from the provider plugin manifest. It
* never checks the provider by name. A missing provider, a missing plugin, a
* non-plugin provider, and a provider without the flag all return false. Both
* login flows share this resolver, so both gates read the same current
* capability.
*/
async function assertSetupTokenLoginProviderCapability(
environmentId: string,
): Promise<void> {
async function resolveProviderSupportsLoginPty(environmentId: string): Promise<boolean> {
const environment = await environmentsSvc.getById(environmentId);
const config =
environment?.config && typeof environment.config === "object"
@@ -1491,13 +1525,39 @@ export function agentRoutes(
driverKey: provider,
})
: null;
if (!resolved?.driver.supportsLoginPty) {
return resolved?.driver.supportsLoginPty === true;
}
/**
* Fails closed when the environment provider does not advertise the login
* pseudo-terminal capability that the Claude setup-token login needs. It reads
* the current provider capability. It fails closed with the fixed, typed error,
* so no session row, lease, or pseudo-terminal starts.
*/
async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise<void> {
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, {
code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
});
}
}
/**
* Fails closed when the environment provider does not advertise the login
* pseudo-terminal capability that the Codex device login needs. It reads the
* current provider capability. The Codex route runs it before any session or
* lease state, and the lease-acquisition path runs it again from current runtime
* state before the provider lease. It fails closed with the fixed, typed error,
* so no session row, lease, or pseudo-terminal starts.
*/
async function assertCodexLoginProviderCapability(environmentId: string): Promise<void> {
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
throw unprocessable(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
code: CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
});
}
}
// Read a login session for its owner. The durable row is the authority for the
// company and the owner. This returns null when the row is absent, when it
// belongs to another company or adapter, or when the requesting user is not the
@@ -3076,12 +3136,19 @@ export function agentRoutes(
req,
res,
deriveOwner: () => assertCanManageAdapterLogin(req, companyId),
guardBeforeValidate: () => assertStreamedExecLoginAdapter(type),
guardBeforeValidate: () => assertDeviceLoginAdapter(type),
requestSchema: startAdapterAuthSessionRequestSchema,
invalidRequestError: "The device login start request is invalid.",
requestOverrides: { adapterType: type },
assertSandbox: (data) =>
assertSandboxLoginEnvironment(companyId, data.environmentId),
assertSandbox: async (data) => {
// The device login runs on a real pseudo-terminal, so it needs a
// provider that advertises the login pseudo-terminal capability. Gate
// the route on the current provider capability before any session or
// lease state. The lease-acquisition path re-checks it from current
// runtime state before the provider lease.
await assertSandboxLoginEnvironment(companyId, data.environmentId);
await assertCodexLoginProviderCapability(data.environmentId);
},
});
if (!resolved) return;
const { ownerUserId: startedByUserId, data } = resolved;
@@ -3130,7 +3197,7 @@ export function agentRoutes(
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertStreamedExecLoginAdapter(type);
assertDeviceLoginAdapter(type);
const owner = await readOwnerLoginSession(
companyId,
@@ -3155,7 +3222,7 @@ export function agentRoutes(
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertStreamedExecLoginAdapter(type);
assertDeviceLoginAdapter(type);
// Scope the cancel to this company, adapter, and owner. A non-owner and a
// cross-company caller both receive a 404 and cannot cancel a session.
@@ -5747,15 +5814,12 @@ export function agentRoutes(
guardAfterValidate: (data) => {
// The setup-token route drives a login on a pseudo-terminal and records a
// stored session identifier on success. It serves any adapter whose
// registry login capability declares that transport and that claim. The
// registry login capability records that completion claim. The
// guard reads the capability, not the adapter name, so a new adapter with
// the same capability passes with no code change. It rejects an adapter
// with no matching capability with a fixed 400.
const capability = getRegistryLoginCapability(data.adapterType);
if (
capability?.sandboxTransport !== "pseudo_terminal" ||
capability.completionClaim !== "storedSessionId"
) {
if (capability?.completionClaim !== "storedSessionId") {
res.status(400).json({
error: "This adapter does not support a setup-token login.",
});
+9
View File
@@ -10752,18 +10752,21 @@ export function issueRoutes(
dependentIssueId: string;
resolvedBlockerIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: Date | string | null;
source: string;
mutation: string;
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
blockedTransitionAt: input.blockedTransitionAt,
});
try {
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
companyId: issue.companyId,
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
blockedTransitionAt: input.blockedTransitionAt,
});
if (existingWake) return;
} catch (err) {
@@ -10948,6 +10951,7 @@ export function issueRoutes(
dependentIssueId: dependent.id,
resolvedBlockerIssueId: issue.id,
blockerIssueIds: dependent.blockerIssueIds,
blockedTransitionAt: dependent.blockedTransitionAt,
source: "issue.blockers_resolved",
mutation: "blocker_done",
});
@@ -10975,6 +10979,7 @@ export function issueRoutes(
dependentIssueId: issue.id,
resolvedBlockerIssueId,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: issue.blockedTransitionAt,
source: "issue.blockers_restored",
mutation: "blocked_dependency_restored",
});
@@ -13101,16 +13106,19 @@ export function issueRoutes(
dependentIssueId: string;
resolvedBlockerIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: Date | string | null;
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
blockedTransitionAt: input.blockedTransitionAt,
});
try {
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
companyId: currentIssue.companyId,
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
blockedTransitionAt: input.blockedTransitionAt,
});
if (existingWake) return;
} catch (err) {
@@ -13274,6 +13282,7 @@ export function issueRoutes(
dependentIssueId: dependent.id,
resolvedBlockerIssueId: currentIssue.id,
blockerIssueIds: dependent.blockerIssueIds,
blockedTransitionAt: dependent.blockedTransitionAt,
});
}
}
+1 -3
View File
@@ -111,11 +111,9 @@ const mockSecretService = vi.hoisted(() => ({
const mockFindActiveServerAdapter = vi.hoisted(() => vi.fn());
// The Claude setup-token login capability the registry declares for the built-in
// adapter. The guard requires the pseudo-terminal transport and the stored-session
// completion claim.
// adapter. The guard requires the stored-session completion claim.
const CLAUDE_LOGIN_CAPABILITY = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
completionClaim: "storedSessionId",
getCommand: () => "",
@@ -0,0 +1,144 @@
// The descriptor-bound credential read for the Codex device login.
//
// The login pseudo-terminal (PTY) runs `codex login --device-auth` and writes
// `auth.json` into the server-controlled session home. The service reads that
// file after the command exits with code zero. The read must close the
// time-of-check-to-time-of-use (TOCTOU) window: a pathname check and a later
// `cat` on the same pathname let an attacker swap the file between the two steps.
//
// This module runs one fixed, server-controlled operation inside the sandbox
// instead. The operation opens the filesystem root, then walks each path
// component of the session home with no symlink follow. `O_NOFOLLOW` protects
// only the final component of one open, so the operation opens every intermediate
// component with a separate no-follow open. A symlink at any ancestor fails the
// walk. The operation then opens `auth.json` relative to the final directory
// descriptor without a symlink follow, runs `fstat` on the opened descriptor, and
// reads only from that same descriptor. The `fstat` check requires a regular
// file, login-user ownership, exact mode `0600`, and a bounded size. A missing
// file, a symlink at any component, a non-regular file, a wrong owner, a wrong
// mode, or an oversize file returns one fixed, non-secret error.
//
// The provider exposes no descriptor application programming interface (API), so
// this module runs a fixed helper program in the sandbox through the environment
// runtime `execute` seam. The helper is node, because the sandbox already runs
// node for the Paperclip bridge. The helper never re-opens the pathname after the
// check: the `fstat` and the read bind to the one opened descriptor.
//
// Security: the helper prints only the base64 of the credential bytes on the
// fully validated success path. It prints one fixed error token on every failed
// path. It never prints the pathname, the raw bytes, or any other detail. This
// module converts any failure to one fixed, non-secret error and reads no bytes.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { validateLoginSessionHome } from "./login-command.js";
/**
* The bounded size for the credential payload. A real subscription `auth.json` is
* a few kilobytes. The read rejects a larger payload before it returns any bytes.
* The value matches the adapter export bound.
*/
export const MAX_AUTH_JSON_BYTES = 64 * 1024;
/** The fixed credential file name inside the session home. No caller controls it. */
export const AUTH_JSON_FILE_NAME = "auth.json";
/**
* The fixed, non-secret error the read returns on every failed path. The message
* carries no pathname, no byte, and no other secret detail.
*/
export const DEVICE_LOGIN_AUTH_READ_ERROR =
"device login failed: the sandbox credential read errored.";
/**
* The fixed helper program. The server reads the helper from its own script file
* and runs it in the sandbox as `node -e <script> <sessionHome> <maxBytes>
* [<expectedUid>]`. The sandbox already runs node for the Paperclip bridge, so the
* helper needs no extra runtime. The helper source lives in
* `scripts/codex-auth-read.cjs`, so no large script stays as a string literal in
* this module.
*
* The helper opens the filesystem root, then opens each session-home path
* component in turn with a no-follow, directory-only open, relative to the
* previous directory descriptor, so a symlink at any component fails. It opens
* `auth.json` relative to the final directory descriptor with `O_NOFOLLOW`, runs
* `fstat` on the opened descriptor, and requires a regular file, the expected
* owner, exact mode `0600`, and a size at or below the bound. It reads only from
* that same descriptor. The `expectedUid` argument is optional; the server omits
* it, so the helper uses the login user's own id.
*/
export const DEVICE_LOGIN_AUTH_READ_SCRIPT = readFileSync(
fileURLToPath(new URL("./scripts/codex-auth-read.cjs", import.meta.url)),
"utf8",
);
/** A strict base64 token: standard alphabet, correct padding, length a multiple
* of four. The read rejects any other stdout, so malformed helper output never
* decodes to partial bytes. */
const STRICT_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
/**
* Decodes the helper stdout to the credential bytes. It accepts only strict
* base64 with correct padding, so a truncated or corrupt line returns the fixed
* error instead of partial bytes. It rejects a decoded payload over the bound.
*/
export function decodeAuthReadOutput(stdout: string): Buffer {
const encoded = stdout.trim();
if (encoded.length % 4 !== 0 || !STRICT_BASE64_RE.test(encoded)) {
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
}
const bytes = Buffer.from(encoded, "base64");
if (bytes.length > MAX_AUTH_JSON_BYTES) {
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
}
return bytes;
}
/** The environment-runtime surface the descriptor-bound read needs. */
export interface DescriptorBoundAuthReadDeps {
environmentRuntime: Pick<EnvironmentRuntimeService, "execute">;
environment: Environment;
lease: EnvironmentLease;
/** The verified, server-controlled session home. */
sessionHome: string;
/** The host timeout for the read command. */
timeoutMs: number;
}
/**
* Runs the descriptor-bound credential read once and returns the bytes. It
* revalidates the session-home shape, then runs the fixed helper as a one-shot,
* non-session command. It converts a non-zero exit, an empty or malformed stdout,
* or an oversize payload to the one fixed, non-secret error. It reads no bytes on
* any failed path.
*/
export async function runDescriptorBoundAuthRead(
deps: DescriptorBoundAuthReadDeps,
): Promise<Buffer> {
// Revalidate the home shape before the sandbox command. The server derived and
// validated it earlier; this second check keeps the helper argument fixed.
validateLoginSessionHome(deps.sessionHome);
let result: { exitCode: number | null; stdout?: string };
try {
result = await deps.environmentRuntime.execute({
environment: deps.environment,
lease: deps.lease,
command: "node",
args: ["-e", DEVICE_LOGIN_AUTH_READ_SCRIPT, deps.sessionHome, String(MAX_AUTH_JSON_BYTES)],
timeoutMs: deps.timeoutMs,
// The read is one fixed, non-session operation. It must not open or reuse
// the login session.
bypassSession: true,
});
} catch {
// A driver error may embed sandbox text. Convert it to the fixed error.
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
}
if (result.exitCode !== 0) {
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
}
return decodeAuthReadOutput(result.stdout ?? "");
}
+207 -44
View File
@@ -20,9 +20,20 @@ import {
type DeviceLoginPrompt,
type SandboxLoginDriver,
} from "@paperclipai/adapter-codex-local/server";
import {
createLoginPtyTransport,
type LoginPtySessionOpener,
} from "@paperclipai/adapter-utils/login-pty-transport";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { environmentService } from "./environments.js";
import { runDescriptorBoundAuthRead } from "./codex-device-login-credential-read.js";
import {
resolveLoginCommandKey,
validateLoginSessionHome,
type LoginCommandKey,
} from "./login-command.js";
import type { LoginPtyWorkerManagerLike } from "./setup-token-transport-binding.js";
// The login-session service. It creates a login session, acquires a fresh
// sandbox lease, runs `codex login --device-auth` through the runner, and owns
@@ -38,6 +49,17 @@ import { environmentService } from "./environments.js";
/** The host timeout for the sandbox login command. It is exactly five minutes. */
export const CODEX_DEVICE_LOGIN_TIMEOUT_MS = 300_000;
// The fixed error for a sandbox provider that does not advertise the login
// pseudo-terminal capability. The Codex device login runs the login command on a
// real pseudo-terminal, so only a provider that advertises the capability can
// host the login. The route returns this specific, typed error and starts no
// session, so an unsupported provider never reaches a session row, a lease, or a
// pseudo-terminal.
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED =
"The sandbox provider does not support the Codex device login.";
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE =
"codex_device_login_provider_unsupported";
/**
* The lease-metadata key that tags a login sandbox lease with its session
* identifier. The runtime stamps it at acquisition. The reaper reads it to find
@@ -1151,80 +1173,197 @@ export function sessionCredentialPath(sessionId: string): string {
/**
* Build the sandbox login driver. This is the one helper the production runtime
* and the tests share. It binds streamed execution and a credential read to the
* environment runtime.
* and the tests share. It runs the login command over the shared login
* pseudo-terminal (PTY) transport and reads the credential with one
* descriptor-bound read.
*
* - `execStreaming` sets an empty session-specific Codex home before the fixed
* login command runs, exports `CODEX_HOME`, and streams standard output to the
* runner. It passes the command through `sh -c`, so the runtime runs the whole
* compound command in one shell. It sets `forceSession`, so the command opens
* the persistent session and streams each standard-output chunk while the
* login command waits for the user. A one-shot exec returns the output only at
* the end, so the login prompt would never reach the user in time.
* - `readFile` reads the credential from the fixed session-specific path with a
* one-shot `cat` command. No caller controls the path.
* - `dispose` is a no-op. The service owns the sandbox delete through a separate
* seam, so the runner's swallowed dispose must not delete the sandbox.
* - `start` runs the fixed login command on a real pseudo-terminal through the
* shared {@link createLoginPtyTransport} and streams each terminal output chunk
* to the runner while the login command waits for the user. The login command
* needs a pseudo-terminal: pipe stdio emits no login prompt. The pseudo-terminal
* opener sets the session-specific `CODEX_HOME` to the same verified session
* home this driver reads.
* - `readFile` runs one descriptor-bound read. It opens the verified session home
* and the fixed credential file with no symlink follow, checks the opened
* descriptor, and reads only from that same descriptor. It ignores the path the
* runner passes, so no caller controls the read target. It reads the fixed
* `auth.json` under the server-controlled session home.
* - `dispose` releases the pseudo-terminal transport, so the host frees the login
* pseudo-terminal slot. The service owns the sandbox delete through a separate
* seam, so this dispose never deletes the sandbox.
*
* The runtime passes `command` and `args` to the provider as a program and its
* argument vector. The provider quotes each element as one shell token. So a
* compound command must run through a shell (`sh -c "<script>"`); a bare
* compound string in `command` becomes one token and never runs.
* The session home is server-controlled and shared: the pseudo-terminal opener
* sets `CODEX_HOME` to it, and the descriptor-bound read opens it. So the login
* command writes `auth.json` into the exact directory the read opens.
*/
export function buildSandboxLoginDriver(deps: {
openPtySession: LoginPtySessionOpener;
environmentRuntime: Pick<EnvironmentRuntimeService, "execute">;
environment: Environment;
lease: EnvironmentLease;
sessionHome: string;
timeoutMs: number;
}): SandboxLoginDriver {
const { environmentRuntime, environment, lease, sessionHome, timeoutMs } = deps;
const { openPtySession, environmentRuntime, environment, lease, sessionHome, timeoutMs } = deps;
const transport = createLoginPtyTransport(openPtySession);
return {
async execStreaming(command, onStdout) {
const script = `rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} ${command}`;
const result = await environmentRuntime.execute({
environment,
lease,
command: "sh",
args: ["-c", script],
timeoutMs,
// Open the persistent session, so the runtime streams each output chunk
// to the runner while the login command waits. A one-shot exec returns
// the output only at the end, so the prompt would arrive too late.
forceSession: true,
onLog: (stream, chunk) => {
if (stream === "stdout") onStdout(chunk);
},
});
return { exitCode: result.exitCode };
async start(command, onData) {
return transport.start(command, onData);
},
async readFile(path) {
const result = await environmentRuntime.execute({
async readFile(_path) {
// The read ignores the runner path. It runs one fixed, server-controlled,
// descriptor-bound operation against the verified session home. So a swap of
// the credential file between a check and the read cannot steer the read.
return runDescriptorBoundAuthRead({
environmentRuntime,
environment,
lease,
command: "cat",
args: [path],
sessionHome,
timeoutMs,
bypassSession: true,
});
return Buffer.from(result.stdout ?? "", "utf8");
},
async dispose() {
// The service owns the sandbox delete. This dispose is a no-op.
// Free the host pseudo-terminal slot before the service deletes the sandbox.
// The service owns the sandbox delete through a separate seam.
await transport.dispose();
},
};
}
/**
* The verified binding one login session hands to the live pseudo-terminal
* opener. The opener sets the sandbox `CODEX_HOME` to `sessionHome`, so the login
* command writes `auth.json` into the exact directory the descriptor-bound read
* opens.
*/
export interface LoginPtySessionBinding {
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
/** The provider lease that binds the sandbox worker. */
providerLeaseId: string;
/** The server-controlled session home. The opener sets `CODEX_HOME` to it. */
sessionHome: string;
/** The resolved environment for the acquired lease. */
environment: Environment;
/** The acquired environment lease. */
lease: EnvironmentLease;
}
/**
* Opens the live pseudo-terminal for one login session and returns the shared
* transport opener. The Daytona provider runs as a plugin worker, so the real
* opener binds inside the worker through the plugin worker manager route. A test
* injects a fake opener to drive the full session path.
*/
export type OpenLoginPtySession = (
binding: LoginPtySessionBinding,
) => Promise<LoginPtySessionOpener>;
/** The fixed, non-secret error the Codex live opener throws when it cannot bind
* the sandbox worker route. It carries no lease detail and no secret. */
const CODEX_LOGIN_PTY_BIND_FAILED =
"device login failed: the sandbox pseudo-terminal transport is not bound.";
/** The dependencies the worker-bound Codex live pseudo-terminal opener needs. */
export interface CodexWorkerBoundLoginPtyOpenerDeps {
/** The plugin worker manager that owns the host route gate. */
workerManager: LoginPtyWorkerManagerLike;
/** A non-leaking status sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
function readLeaseMetaString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
/**
* Builds the production Codex `openLivePtySession`. It drives the sandbox worker
* through the manager route gate. The manager mints the host route identifier and
* owns the route lifecycle.
*
* The opener passes the binding's server-controlled `sessionHome` to the worker
* route. That home is the exact directory the descriptor-bound credential read
* opens, so the sandbox `CODEX_HOME` and the read target match. The opener never
* derives a fresh home: a fresh home would set `CODEX_HOME` to a directory the
* read never opens, and every Codex login would fail closed.
*
* The opener resolves the closed login command key from the trusted adapter type,
* never from the caller. It validates the session home shape before the worker
* RPC. It fails closed when the lease carries no sandbox worker binding.
*/
export function createCodexWorkerBoundLoginPtyOpener(
deps: CodexWorkerBoundLoginPtyOpenerDeps,
): OpenLoginPtySession {
const log = deps.log ?? (() => {});
return async (binding) => {
const metadata =
binding.lease.metadata && typeof binding.lease.metadata === "object"
? (binding.lease.metadata as Record<string, unknown>)
: {};
const pluginId = readLeaseMetaString(metadata.pluginId);
const driverKey =
readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
if (!binding.providerLeaseId || !pluginId || !driverKey) {
log("[paperclip] Device login: the lease carries no sandbox worker binding.");
throw new Error(CODEX_LOGIN_PTY_BIND_FAILED);
}
// Resolve the closed command key from the trusted adapter type. An unmapped
// adapter fails closed before the worker RPC.
let loginCommandKey: LoginCommandKey;
try {
loginCommandKey = resolveLoginCommandKey(binding.adapterType);
} catch {
log("[paperclip] Device login: the adapter type has no login command key.");
throw new Error(CODEX_LOGIN_PTY_BIND_FAILED);
}
// Validate the server-controlled session home shape before the worker RPC.
// The runtime derived it from the session id; the manager revalidates it too.
validateLoginSessionHome(binding.sessionHome);
// The opener argument is the runner's fixed command string. It confers no
// command authority, so the opener ignores it and returns a host-bound opener.
return (_command: string) =>
deps.workerManager.openLoginPtySession(pluginId, {
driverKey,
companyId: binding.companyId,
environmentId: binding.environmentId,
providerLeaseId: binding.providerLeaseId,
loginCommandKey,
sessionHome: binding.sessionHome,
});
};
}
export interface ProductionLoginSessionRuntimeDeps {
db: Db;
environmentRuntime: EnvironmentRuntimeService;
/**
* Re-checks the provider login pseudo-terminal capability from current runtime
* state, immediately before the provider lease. The route gate already ran, so
* a managed reconciliation can rebind the environment to an unsupported
* provider between the route gate and this acquire. The check reads the current
* provider capability, not the stale route decision. It throws the fixed
* unsupported-provider error, so the runtime creates no lease and opens no
* pseudo-terminal for an unsupported provider.
*/
assertProviderSupportsLoginPty: (environmentId: string) => Promise<void>;
/**
* Opens the live pseudo-terminal for the acquired lease. When a caller omits
* it, the runtime binds a fail-closed opener: the login run then fails and the
* service deletes the sandbox. The live opener binds the sandbox worker route.
*/
openLivePtySession?: OpenLoginPtySession;
}
/** The fixed, non-secret error the fail-closed opener throws when no live
* pseudo-terminal opener is bound. */
const LOGIN_PTY_OPENER_UNBOUND = "device login failed: the sandbox pseudo-terminal transport is not bound.";
/**
* Build the production login-session runtime. It acquires a fresh lease with
* reuse disabled (no heartbeat run, no execution workspace) and the active
* custom-image template applied, binds the sandbox login driver, and owns the
* delete and release seams.
* custom-image template applied, binds the sandbox login driver over the shared
* pseudo-terminal transport, and owns the delete and release seams.
*/
export function createProductionLoginSessionRuntime(
deps: ProductionLoginSessionRuntimeDeps,
@@ -1236,6 +1375,14 @@ export function createProductionLoginSessionRuntime(
if (!environment) {
throw new Error(`Environment "${input.environmentId}" is not found.`);
}
// Re-check the provider login pseudo-terminal capability from current
// runtime state, immediately before the provider lease. A managed
// reconciliation can rebind the environment to an unsupported provider
// between the route gate and this acquire, so the check reads the current
// capability, not the stale route decision. It throws the fixed
// unsupported-provider error, so the runtime acquires no lease and opens no
// pseudo-terminal for an unsupported provider.
await deps.assertProviderSupportsLoginPty(input.environmentId);
const record = await deps.environmentRuntime.acquireRunLease(
buildLoginLeaseAcquireArgs({
metadata: {
@@ -1254,7 +1401,23 @@ export function createProductionLoginSessionRuntime(
const sessionHome = sessionCodexHomePath(input.sessionId);
const authPath = sessionCredentialPath(input.sessionId);
const providerLeaseId = record.lease.providerLeaseId ?? record.lease.id;
// Resolve the live pseudo-terminal opener for this lease. When no live
// opener is bound, use a fail-closed opener: the login run then fails and
// the service deletes the sandbox. The opener sets `CODEX_HOME` to the same
// `sessionHome` the descriptor-bound read opens.
const openPtySession: LoginPtySessionOpener = deps.openLivePtySession
? await deps.openLivePtySession({
companyId: input.companyId,
environmentId: input.environmentId,
adapterType: input.adapterType,
providerLeaseId,
sessionHome,
environment: record.environment,
lease: record.lease,
})
: () => Promise.reject(new Error(LOGIN_PTY_OPENER_UNBOUND));
const driver = buildSandboxLoginDriver({
openPtySession,
environmentRuntime: deps.environmentRuntime,
environment: record.environment,
lease: record.lease,
@@ -6,6 +6,7 @@ import {
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import {
clampSpanLabel,
getActiveStepContext,
@@ -206,6 +207,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// enters the sandbox environment. Absent keeps the safe no-op default in the
// bridge, so the surface stays inert until the host injects a real recorder.
duplexTelemetryRecorder?: DuplexTelemetryRecorder | null;
// The process-owned aggregate byte ledger. The seam stamps it onto the sandbox
// target next to the runner, so the live object stays on the host and never
// enters the sandbox environment. Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
return {
@@ -323,6 +328,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// binds it to the fixed observability surface. Absent keeps the no-op
// default, so the surface stays inert on a run with no injected recorder.
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
// Attach the process-owned aggregate byte ledger next to the runner. The
// bridge passes it to the broker, the decoder, and the response-body reader.
// Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger: input.duplexAggregateByteLedger ?? null,
...(effectiveCapabilities ? { effectiveCapabilities: Object.freeze({ ...effectiveCapabilities }) } : {}),
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
@@ -43,6 +43,7 @@ import {
type AdapterWorkspaceRealization,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { logActivity } from "./activity-log.js";
@@ -154,6 +155,14 @@ export function environmentRunOrchestrator(
options: {
pluginWorkerManager?: PluginWorkerManager;
environmentRuntime?: EnvironmentRuntimeService;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel.
* The server root creates one ledger per host process and injects the same
* object here. The orchestrator stamps it onto the sandbox execution target,
* so one shared gauge bounds the aggregate retained bytes across all live
* duplex routes. Absent keeps the bridge inert for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
} = {},
) {
const environmentsSvc = environmentService(db);
@@ -523,6 +532,7 @@ export function environmentRunOrchestrator(
lease,
environmentRuntime,
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
duplexAggregateByteLedger: options.duplexAggregateByteLedger ?? null,
});
const realizationMode = workspaceRealization.mode === "in_place" ? "in_place" : "copy";
const authoritativeRoot =
+3 -2
View File
@@ -1080,9 +1080,10 @@ function adaptDuplexChannelHostSession(
onData(listener: (chunk: string) => void): void {
session.onData(listener);
},
onExit(listener: (exit: { exitCode: number | null }) => void): void {
onExit(listener: (exit: { exitCode: number | null; transportClosed?: boolean }) => void): void {
// `wait()` resolves one time with the exit and never rejects, so a single
// `then` bridges it to the one-time exit listener.
// `then` bridges it to the one-time exit listener. The exit carries
// `transportClosed`, so the broker tells a real exit from a transport close.
void session.wait().then((exit) => {
listener(exit);
});
+10
View File
@@ -90,6 +90,7 @@ import {
import { conflict, HttpError, notFound } from "../errors.js";
import { getStartupTraceContext, getStartupTracer } from "../instrumentation.js";
import { createHostDuplexTelemetryRecorder } from "./duplex-telemetry-recorder.js";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { incrementToolRuntimeMetricCounter } from "./tool-runtime-metrics.js";
import { logger } from "../middleware/logger.js";
import {
@@ -7741,6 +7742,14 @@ export interface HeartbeatServiceOptions {
nativeSessionBackendFactory?: (
execution: NativeExecutionInput,
) => NativeSessionBackend;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel. The
* server root creates one ledger per host process and injects the same object
* here. The heartbeat threads it into the environment run orchestrator, which
* stamps it onto the sandbox execution target. Absent keeps the bridge inert
* for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
type WorkspaceReadyCommentWriter = {
@@ -7887,6 +7896,7 @@ export function heartbeatService(
const envOrchestrator = environmentRunOrchestrator(db, {
pluginWorkerManager: options.pluginWorkerManager,
environmentRuntime,
duplexAggregateByteLedger: options.duplexAggregateByteLedger,
});
const workspaceOperationsSvc = workspaceOperationService(db);
const liveRunExecutions = {
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import {
buildIssueBlockersResolvedWakeIdempotencyKey,
buildIssueBlockersResolvedWakeStateKey,
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
findExistingIssueBlockersResolvedWakeForReadyState,
} from "./issue-dependency-wakeups.js";
const dependentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const blockerIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const companyId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const firstCycle = new Date("2026-04-01T12:00:00.000Z");
const secondCycle = new Date("2026-08-01T09:30:00.000Z");
type WakeRow = {
id: string;
status: string;
idempotencyKey: string | null;
requestedAt: Date;
};
function dbWithWakes(rows: WakeRow[]): Db {
return {
select() {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
},
} as unknown as Db;
}
describe("buildIssueBlockersResolvedWakeStateKey", () => {
it("is identical for the same dependent, blockers, and blockedTransitionAt", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle.toISOString(),
});
expect(first).toBe(second);
expect(first).toContain(dependentIssueId);
});
it("changes when blockedTransitionAt changes", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
});
expect(first).not.toBe(second);
});
it("hashes a null cycle as none and differs from any timestamp", () => {
const noneKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
});
const omittedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const datedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
expect(noneKey).toBe(omittedKey);
expect(noneKey).not.toBe(datedKey);
expect(noneKey).not.toBe(
buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
}),
);
});
});
describe("findExistingIssueBlockersResolvedWakeForReadyState", () => {
const readyState = {
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
};
it("suppresses a completed wake on the cycle-aware state key", async () => {
const cycleKey = buildIssueBlockersResolvedWakeStateKey(readyState);
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-cycle",
status: "completed",
idempotencyKey: cycleKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-cycle");
});
it("does not let a completed old-key wake from a previous blocked cycle suppress", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-previous-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing).toBeNull();
});
it("suppresses a completed old-key wake requested at or after blockedTransitionAt", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-same-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-same-cycle");
});
it("suppresses a completed old-key wake when blockedTransitionAt is null", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-no-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
{
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
},
);
expect(existing?.id).toBe("wake-old-no-cycle");
});
it("suppresses an in-flight old-key wake across a later blocked cycle", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-queued",
status: "queued",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-queued");
});
it("keeps legacy per-edge matching in-flight only", async () => {
const legacyKey = buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId,
resolvedBlockerIssueId: blockerIssueId,
});
const inFlight = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-claimed",
status: "claimed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(inFlight?.id).toBe("wake-legacy-claimed");
const completed = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-completed",
status: "completed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(completed).toBeNull();
});
});
+151 -51
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, or } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests } from "@paperclipai/db";
@@ -19,14 +19,62 @@ const IDEMPOTENT_DEPENDENCY_WAKE_STATUSES = [
// A wake counts as "still in flight" for these statuses. The `completed` status
// is not in this set on purpose. Dependency readiness is level-triggered, so a
// historical completed per-edge wake must never suppress a new wake for the
// current ready state. The dedup uses this set only for the legacy per-edge key,
// to avoid a duplicate while an old-format wake is still queued or claimed.
// current ready state. The dedup uses this set only for the legacy per-edge key
// and for old no-cycle state keys that are still queued after a deploy.
const IN_FLIGHT_DEPENDENCY_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
"claimed",
] as const;
const IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_DEPENDENCY_WAKE_STATUSES);
const IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET = new Set<string>(IN_FLIGHT_DEPENDENCY_WAKE_STATUSES);
export type IssueBlockersResolvedWakeCycleInput = Date | string | null | undefined;
export type IssueBlockersResolvedReadyStateInput = {
dependentIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput;
};
/**
* Canonical blocked-cycle stamp for the dependency-ready state key.
* `blockedTransitionAt` is UTC ISO-8601, or `none` when the dependent has no
* recorded transition into `blocked`.
*/
export function formatIssueBlockersResolvedWakeCycle(
blockedTransitionAt: IssueBlockersResolvedWakeCycleInput,
): string {
if (blockedTransitionAt == null || blockedTransitionAt === "") return "none";
const parsed = blockedTransitionAt instanceof Date
? blockedTransitionAt
: new Date(blockedTransitionAt);
if (Number.isNaN(parsed.getTime())) return "none";
return parsed.toISOString();
}
function uniqueSortedBlockerIssueIds(blockerIssueIds: string[]): string[] {
return [...new Set(blockerIssueIds.filter(Boolean))].sort();
}
function hashBlockerReadyStateDigest(sortedBlockerIssueIds: string[], cycle: string | null): string {
const payload = cycle == null
? sortedBlockerIssueIds.join(",")
: `${sortedBlockerIssueIds.join(",")}\n${cycle}`;
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
function buildStateKey(dependentIssueId: string, digest: string, blockerCount: number): string {
return [
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
"state",
dependentIssueId,
String(blockerCount),
digest,
].join(":");
}
/**
* Legacy per-edge idempotency key. One key encodes a single resolved blocker
* edge `issue_blockers_resolved:{dependentIssueId}:{resolvedBlockerIssueId}`.
@@ -45,42 +93,96 @@ export function buildIssueBlockersResolvedWakeIdempotencyKey(input: {
}
/**
* Level-triggered idempotency key. One key encodes the full set of blockers that
* defines the current dependency-ready state. Two wakes for the same ready state
* share the key. A wake for an earlier partial state has a different blocker set,
* so it produces a different key and never suppresses the current wake. All three
* emit paths (route-time, finalize-time, periodic backstop) use this key so they
* share one idempotency rule.
* Pre-cycle level-triggered key. Rows written before the ready state included
* `blockedTransitionAt` hashed only the sorted blocker ids. Lookup still reads
* this format so an in-flight deploy-overlap wake can suppress a duplicate.
*/
export function buildIssueBlockersResolvedWakeStateKey(input: {
export function buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input: {
dependentIssueId: string;
blockerIssueIds: string[];
}) {
const sortedBlockerIssueIds = [...new Set(input.blockerIssueIds.filter(Boolean))].sort();
const digest = createHash("sha256")
.update(sortedBlockerIssueIds.join(","))
.digest("hex")
.slice(0, 32);
return [
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
"state",
const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds);
return buildStateKey(
input.dependentIssueId,
String(sortedBlockerIssueIds.length),
digest,
].join(":");
hashBlockerReadyStateDigest(sortedBlockerIssueIds, null),
sortedBlockerIssueIds.length,
);
}
/**
* Level-triggered idempotency key. One key encodes the full set of blockers that
* defines the current dependency-ready state plus the dependent's current
* blocked cycle (`blockedTransitionAt`, or `none`). Two wakes for the same ready
* state share the key. A wake from an earlier blocked cycle has a different
* cycle stamp, so it produces a different key and never suppresses the current
* wake. All three emit paths (route-time, finalize-time, periodic backstop) use
* this key so they share one idempotency rule.
*/
export function buildIssueBlockersResolvedWakeStateKey(input: IssueBlockersResolvedReadyStateInput) {
const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds);
const cycle = formatIssueBlockersResolvedWakeCycle(input.blockedTransitionAt);
return buildStateKey(
input.dependentIssueId,
hashBlockerReadyStateDigest(sortedBlockerIssueIds, cycle),
sortedBlockerIssueIds.length,
);
}
function parseWakeCycleDate(blockedTransitionAt: IssueBlockersResolvedWakeCycleInput): Date | null {
if (blockedTransitionAt == null || blockedTransitionAt === "") return null;
const parsed = blockedTransitionAt instanceof Date
? blockedTransitionAt
: new Date(blockedTransitionAt);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function wakeCoversIssueBlockersResolvedReadyState(
wake: {
status: string;
idempotencyKey: string | null;
requestedAt: Date;
},
keys: {
cycleKey: string;
oldStateKey: string;
legacyKeys: Set<string>;
blockedTransitionAt: Date | null;
},
): boolean {
const idempotencyKey = wake.idempotencyKey;
if (!idempotencyKey) return false;
if (idempotencyKey === keys.cycleKey) {
return IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status);
}
if (idempotencyKey === keys.oldStateKey) {
if (IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status)) return true;
if (wake.status !== "completed") return false;
if (!keys.blockedTransitionAt) return true;
return wake.requestedAt.getTime() >= keys.blockedTransitionAt.getTime();
}
if (keys.legacyKeys.has(idempotencyKey)) {
return IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status);
}
return false;
}
/**
* Find a wake that already covers the current dependency-ready state of the
* dependent issue. The check is level-triggered:
* dependent issue. The check is level-triggered and cycle-aware:
*
* - The state key matches a wake in any idempotent status (including
* `completed`). This suppresses a duplicate wake for the SAME ready state and
* bounds reconciliation.
* - Each legacy per-edge key matches only a wake that is still in flight
* (`queued`, `deferred_issue_execution`, `claimed`). This prevents a duplicate
* wake while an old-format wake is still pending after a deploy, but it never
* lets a historical completed per-edge wake strand the issue.
* - The cycle-aware state key matches a wake in any idempotent status
* (including `completed`). This suppresses a duplicate for the SAME ready
* state, including the current blocked cycle.
* - The old no-cycle state key matches in-flight statuses (deploy overlap),
* or a `completed` wake whose `requestedAt` is at or after the current
* `blockedTransitionAt` (same cycle). A completed old-key wake from a
* previous cycle does not suppress.
* - Each legacy per-edge key matches only a wake that is still in flight.
*
* Returns the first matching wake or `null`.
*/
@@ -90,13 +192,12 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
companyId: string;
dependentIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput;
},
) {
const stateKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
});
const legacyKeys = [
const cycleKey = buildIssueBlockersResolvedWakeStateKey(input);
const oldStateKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input);
const legacyKeyList = [
...new Set(
input.blockerIssueIds
.filter(Boolean)
@@ -108,32 +209,31 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
),
),
];
const lookupKeys = [...new Set([cycleKey, oldStateKey, ...legacyKeyList])];
const blockedTransitionAt = parseWakeCycleDate(input.blockedTransitionAt);
const stateMatch = and(
eq(agentWakeupRequests.idempotencyKey, stateKey),
inArray(agentWakeupRequests.status, [...IDEMPOTENT_DEPENDENCY_WAKE_STATUSES]),
);
const legacyMatch =
legacyKeys.length > 0
? and(
inArray(agentWakeupRequests.idempotencyKey, legacyKeys),
inArray(agentWakeupRequests.status, [...IN_FLIGHT_DEPENDENCY_WAKE_STATUSES]),
)
: null;
return db
const rows = await db
.select({
id: agentWakeupRequests.id,
status: agentWakeupRequests.status,
idempotencyKey: agentWakeupRequests.idempotencyKey,
requestedAt: agentWakeupRequests.requestedAt,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, input.companyId),
legacyMatch ? or(stateMatch, legacyMatch) : stateMatch,
inArray(agentWakeupRequests.idempotencyKey, lookupKeys),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
);
const covering = rows.find((row) =>
wakeCoversIssueBlockersResolvedReadyState(row, {
cycleKey,
oldStateKey,
legacyKeys: new Set(legacyKeyList),
blockedTransitionAt,
}),
);
return covering ?? null;
}
+2
View File
@@ -6608,6 +6608,7 @@ export function issueService(db: Db) {
id: issues.id,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
blockedTransitionAt: issues.blockedTransitionAt,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
@@ -6647,6 +6648,7 @@ export function issueService(db: Db) {
id: candidate.id,
assigneeAgentId: candidate.assigneeAgentId!,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
}));
},
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
deriveLoginSessionHome,
isLoginCommandKey,
isLoginCommandSupportedAdapterType,
LOGIN_SESSION_HOME_ROOT,
resolveLoginCommandKey,
validateLoginSessionHome,
} from "./login-command.js";
const UUID = "11111111-2222-4333-8444-555555555555";
describe("resolveLoginCommandKey", () => {
it("resolves the trusted adapter type to the closed key", () => {
expect(resolveLoginCommandKey("claude_local")).toBe("claude");
expect(resolveLoginCommandKey("codex_local")).toBe("codex");
});
it("fails closed for an unmapped adapter type", () => {
// The provider driver key and an unknown adapter confer no command authority.
expect(() => resolveLoginCommandKey("daytona")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
expect(() => resolveLoginCommandKey("gemini_local")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
expect(() => resolveLoginCommandKey("")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
});
});
describe("isLoginCommandKey", () => {
it("accepts only the closed key set", () => {
expect(isLoginCommandKey("claude")).toBe(true);
expect(isLoginCommandKey("codex")).toBe(true);
expect(isLoginCommandKey("gemini")).toBe(false);
expect(isLoginCommandKey("rm -rf /")).toBe(false);
expect(isLoginCommandKey(undefined)).toBe(false);
});
});
describe("isLoginCommandSupportedAdapterType", () => {
it("accepts only the adapter types the closed command map holds", () => {
// The admission guard reads this predicate, so it must match the map that
// the opener resolves. The mapped types pass; an unmapped type fails closed.
expect(isLoginCommandSupportedAdapterType("claude_local")).toBe(true);
expect(isLoginCommandSupportedAdapterType("codex_local")).toBe(true);
expect(isLoginCommandSupportedAdapterType("gemini_local")).toBe(false);
expect(isLoginCommandSupportedAdapterType("daytona")).toBe(false);
expect(isLoginCommandSupportedAdapterType("")).toBe(false);
});
});
describe("deriveLoginSessionHome", () => {
it("derives the exact UUID-form absolute path", () => {
expect(deriveLoginSessionHome(UUID)).toBe(`${LOGIN_SESSION_HOME_ROOT}/${UUID}`);
});
it("rejects a non-UUID id", () => {
expect(() => deriveLoginSessionHome("not-a-uuid")).toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
expect(() => deriveLoginSessionHome("")).toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
});
});
describe("validateLoginSessionHome", () => {
it("accepts the exact derived path shape", () => {
expect(() => validateLoginSessionHome(`${LOGIN_SESSION_HOME_ROOT}/${UUID}`)).not.toThrow();
});
it("rejects an empty, relative, traversal, whitespace, control, or metacharacter candidate", () => {
const candidates = [
"",
"paperclip-adapter-login/" + UUID,
`${LOGIN_SESSION_HOME_ROOT}/../${UUID}`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID}/..`,
`${LOGIN_SESSION_HOME_ROOT}/ ${UUID}`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID} `,
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\n`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\t`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\0`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID};rm -rf /`,
`${LOGIN_SESSION_HOME_ROOT}/$(id)`,
`${LOGIN_SESSION_HOME_ROOT}/${UUID}&&whoami`,
`/tmp/other/${UUID}`,
`${LOGIN_SESSION_HOME_ROOT}/NOTHEX11-2222-4333-8444-555555555555`,
];
for (const candidate of candidates) {
expect(() => validateLoginSessionHome(candidate), candidate).toThrow(
"LOGIN_PTY_INVALID_SESSION_HOME",
);
}
});
});
+105
View File
@@ -0,0 +1,105 @@
// The host-owned login command contract for the shared login pseudo-terminal
// (PTY). The shared login PTY runs one fixed command. The host selects that
// command from a closed internal key, never from a request, a configuration, a
// plugin manifest, or the worker routing key. This module holds the closed key
// union, the exhaustive resolver from the trusted adapter type, and the
// server-controlled session home helpers.
//
// Security boundary: the key is the only command authority. The provider driver
// key routes the worker; it confers no command authority. A caller cannot pass a
// command, so the sandbox pseudo-terminal never runs an arbitrary process.
/**
* The closed set of login command identities. The host resolves the key from the
* trusted adapter type. The worker maps the key to a compile-time command. The
* union is exhaustive: a value outside it fails closed before the worker RPC.
*/
export type LoginCommandKey = "claude" | "codex";
/**
* The exhaustive map from the trusted adapter type to the login command key. The
* host reads only this map. It never reads the key from a request, a
* configuration, a plugin manifest, or the provider driver key.
*/
const ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY: Readonly<Record<string, LoginCommandKey>> = {
claude_local: "claude",
codex_local: "codex",
};
/** The fixed non-secret error an unsupported adapter type returns. */
export const LOGIN_COMMAND_UNSUPPORTED_ADAPTER = "LOGIN_PTY_UNSUPPORTED_ADAPTER";
/** The fixed non-secret error an invalid session home returns. */
export const LOGIN_SESSION_HOME_INVALID = "LOGIN_PTY_INVALID_SESSION_HOME";
/**
* Resolves the login command key from the trusted adapter type. It reads only the
* exhaustive first-party map. It throws the fixed non-secret error for an adapter
* type that has no mapped key, so an unknown adapter fails closed before the
* worker RPC.
*/
export function resolveLoginCommandKey(adapterType: string): LoginCommandKey {
const key = ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY[adapterType];
if (!key) {
throw new Error(LOGIN_COMMAND_UNSUPPORTED_ADAPTER);
}
return key;
}
/** Reports whether a value is a member of the closed login command key set. */
export function isLoginCommandKey(value: unknown): value is LoginCommandKey {
return value === "claude" || value === "codex";
}
/**
* Reports whether the trusted adapter type maps to a login command key. The
* admission guard reads this predicate, so an adapter type with no mapped key
* fails closed at admission. The device login opener resolves the same closed
* map later, so admission and command resolution stay consistent.
*/
export function isLoginCommandSupportedAdapterType(adapterType: string): boolean {
return ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY[adapterType] !== undefined;
}
/**
* The fixed root for a login session home. The server derives one per-session
* home under this root. The path shape is exact, so the provider revalidates it
* before it touches the filesystem.
*/
export const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login";
/** Matches one lowercase-hyphenated UUID (version 4 layout not enforced). */
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* The exact absolute path shape for a login session home. The path is the fixed
* root, one slash, and one UUID. The anchors reject a relative path, a path
* traversal, whitespace, a control character, and a shell metacharacter, because
* none of those match the UUID or the fixed root.
*/
const SESSION_HOME_PATTERN = new RegExp(
`^${LOGIN_SESSION_HOME_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
);
/**
* Derives the session home from a session UUID. It builds the exact
* `/tmp/paperclip-adapter-login/<uuid>` path. It throws the fixed non-secret
* error when the id is not a UUID, so a malformed id never becomes a home.
*/
export function deriveLoginSessionHome(sessionUuid: string): string {
if (!UUID_PATTERN.test(sessionUuid)) {
throw new Error(LOGIN_SESSION_HOME_INVALID);
}
return `${LOGIN_SESSION_HOME_ROOT}/${sessionUuid}`;
}
/**
* Validates the exact session home path shape. It throws the fixed non-secret
* error for an empty, a relative, a traversal, a whitespace, a control-character,
* or a shell-metacharacter candidate. The server validates before the worker RPC;
* the provider revalidates before the filesystem operation.
*/
export function validateLoginSessionHome(sessionHome: string): void {
if (!SESSION_HOME_PATTERN.test(sessionHome)) {
throw new Error(LOGIN_SESSION_HOME_INVALID);
}
}
File diff suppressed because it is too large. Load diff
+4
View File
@@ -5725,6 +5725,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
companyId: issues.companyId,
identifier: issues.identifier,
assigneeAgentId: issues.assigneeAgentId,
blockedTransitionAt: issues.blockedTransitionAt,
totalCount: sql<number>`count(*) over()::int`,
})
.from(issueRelations)
@@ -5740,6 +5741,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
companyId: issues.companyId,
identifier: issues.identifier,
assigneeAgentId: issues.assigneeAgentId,
blockedTransitionAt: issues.blockedTransitionAt,
totalCount: sql<number>`count(*) over()::int`,
})
.from(issues)
@@ -5813,11 +5815,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: candidate.id,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
});
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
companyId,
dependentIssueId: candidate.id,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
});
if (existingWake) {
result.existingWakeSkipped += 1;
@@ -0,0 +1,93 @@
// The descriptor-bound credential read helper. The server runs it in the sandbox
// as `node -e <this file> <sessionHome> <maxBytes> [<expectedUid>]`. The sandbox
// already runs node for the Paperclip bridge, so the helper needs no extra
// runtime.
//
// The helper closes the time-of-check-to-time-of-use (TOCTOU) window. It opens
// the filesystem root, then opens each session-home path component in turn with a
// no-follow, directory-only open, relative to the previous directory descriptor. A
// symlink at any component fails the walk. Node has no `openat`, so the helper
// opens each next component through `/proc/self/fd/<dfd>/<component>`: the kernel
// resolves the magic descriptor link, then applies `O_NOFOLLOW` to the final
// component. This binds each open to the descriptor inode, not to a re-resolved
// pathname, so it matches an `openat` walk.
//
// The helper opens `auth.json` relative to the final directory descriptor with
// `O_NOFOLLOW`, runs `fstat` on that one descriptor, and reads only from it. The
// `fstat` check requires a regular file, the expected owner, exact mode `0600`,
// and a size at or below the bound. The helper never re-opens the pathname after
// the check.
//
// Security: the helper prints only the base64 of the credential bytes on the
// fully validated success path. It prints one fixed error token on stderr on every
// failed path. It never prints the pathname, the raw bytes, or any other detail.
"use strict";
const fs = require("node:fs");
const NAME = "auth.json";
const home = process.argv[1];
const max = Number.parseInt(process.argv[2], 10);
const expectedUid =
process.argv[3] !== undefined ? Number.parseInt(process.argv[3], 10) : process.getuid();
function fail() {
process.stderr.write("AUTH_READ_FAILED\n");
process.exit(1);
}
if (!Number.isInteger(max) || max <= 0) fail();
if (typeof home !== "string" || !home.startsWith("/")) fail();
const parts = home.split("/").filter((component) => component.length > 0);
if (parts.length === 0) fail();
// Open each component with a no-follow, directory-only open relative to the
// previous descriptor. A symlink or a non-directory at any component throws, so
// the read fails closed and never leaves the trusted tree.
let dfd;
try {
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
} catch {
fail();
}
for (const part of parts) {
try {
dfd = fs.openSync(
`/proc/self/fd/${dfd}/${part}`,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
} catch {
fail();
}
}
// Open the credential relative to the final directory descriptor with a no-follow
// open, so a symlink credential fails and a missing credential fails.
let ffd;
try {
ffd = fs.openSync(`/proc/self/fd/${dfd}/${NAME}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
} catch {
fail();
}
// Validate the opened descriptor, then read only from it.
const st = fs.fstatSync(ffd);
if (!st.isFile()) fail();
if (st.uid !== expectedUid) fail();
if ((st.mode & 0o7777) !== 0o600) fail();
if (st.size > max) fail();
const buffer = Buffer.allocUnsafe(65536);
const chunks = [];
let total = 0;
for (;;) {
const read = fs.readSync(ffd, buffer, 0, buffer.length, null);
if (read === 0) break;
chunks.push(Buffer.from(buffer.subarray(0, read)));
total += read;
if (total > max) fail();
}
process.stdout.write(Buffer.concat(chunks).toString("base64"));
process.exit(0);
@@ -5,7 +5,7 @@ import {
buildSetupTokenLoginTransport,
createProductionSetupTokenSandboxProvider,
createSetupTokenSecretWriter,
createWorkerBoundSetupTokenPtyOpener,
createWorkerBoundLoginPtyOpener,
type SetupTokenSandboxProvider,
} from "./setup-token-transport-binding.js";
import {
@@ -18,7 +18,7 @@ import {
type SetupTokenSessionScope,
type SetupTokenSessionState,
} from "./setup-token-session.js";
import type { SetupTokenPtySessionOpener } from "@paperclipai/adapter-utils/setup-token-transport";
import type { LoginPtySessionOpener } from "@paperclipai/adapter-utils/login-pty-transport";
import {
CLAUDE_SETUP_TOKEN_COMMAND,
SETUP_TOKEN_AFTER_ANCHOR,
@@ -54,7 +54,7 @@ function createFakeSandbox() {
resolveExit = resolve;
});
const openPtySession: SetupTokenPtySessionOpener = async (command) => {
const openPtySession: LoginPtySessionOpener = async (command) => {
openedCommands.push(command);
return {
onData(): void {},
@@ -410,7 +410,7 @@ describe("production sandbox provider", () => {
});
it("acquires a lease and binds the live opener when it is present", async () => {
const openPtySession: SetupTokenPtySessionOpener = async () => ({
const openPtySession: LoginPtySessionOpener = async () => ({
onData(): void {},
write(): void {},
wait: async () => ({ exitCode: 0 }),
@@ -446,7 +446,7 @@ describe("production sandbox provider", () => {
});
it("forwards the session deadline and records a lease expiry at or before it", async () => {
const openPtySession: SetupTokenPtySessionOpener = async () => ({
const openPtySession: LoginPtySessionOpener = async () => ({
onData(): void {},
write(): void {},
wait: async () => ({ exitCode: 0 }),
@@ -569,14 +569,16 @@ describe("worker-bound live pseudo-terminal opener", () => {
kill: () => {},
close: async () => {},
};
const openSetupTokenPtySession = vi.fn(async () => session);
const openLoginPtySession = vi.fn(
async (_pluginId: string, _input: Record<string, unknown>) => session,
);
const getLeaseById = vi.fn(async () => ({
providerLeaseId: "provider-lease-9",
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
}));
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
workerManager: { openSetupTokenPtySession },
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
environments: { getLeaseById },
});
@@ -588,24 +590,64 @@ describe("worker-bound live pseudo-terminal opener", () => {
// The opener resolved the server lease id to the provider lease id.
expect(getLeaseById).toHaveBeenCalledWith("lease-42");
const opened = await opener("claude setup-token");
// The opener drove the manager route gate with the resolved worker target and
// the fixed command. The manager mints the host route id, so the opener passes
// none.
expect(openSetupTokenPtySession).toHaveBeenCalledWith("paperclip.daytona", {
// The opener ignores the incoming command argument. It resolves the closed
// command key from the trusted adapter type and derives the session home.
const opened = await opener("caller-supplied-ignored");
// The opener drove the manager route gate with the resolved worker target, the
// closed command key, and a validated session home. The manager mints the host
// route id, so the opener passes none. The open carries no command string.
expect(openLoginPtySession).toHaveBeenCalledTimes(1);
const [pluginId, openInput] = openLoginPtySession.mock.calls[0];
expect(pluginId).toBe("paperclip.daytona");
expect(openInput).toMatchObject({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "provider-lease-9",
command: "claude setup-token",
loginCommandKey: "claude",
});
expect(openInput).not.toHaveProperty("command");
// The session home is the fixed root, one slash, and one UUID.
expect(openInput.sessionHome).toMatch(
/^\/tmp\/paperclip-adapter-login\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(opened).toBe(session);
});
it("fails closed when the adapter type has no login command key", async () => {
const openLoginPtySession = vi.fn(async () => ({
onData: () => {},
write: () => {},
wait: async () => ({ exitCode: 0 }),
kill: () => {},
close: async () => {},
}));
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
environments: {
getLeaseById: async () => ({
providerLeaseId: "provider-lease-9",
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
}),
},
});
// An unmapped adapter type confers no command authority. The opener fails
// closed before it reaches the worker manager.
await expect(
openLivePtySession({
scope: { ...SCOPE, adapterType: "gemini_local" },
environmentId: "env-1",
leaseId: "lease-42",
}),
).rejects.toMatchObject({ status: 503 });
expect(openLoginPtySession).not.toHaveBeenCalled();
});
it("fails closed when the lease carries no sandbox worker binding", async () => {
const openSetupTokenPtySession = vi.fn();
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
workerManager: { openSetupTokenPtySession },
const openLoginPtySession = vi.fn();
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
// The lease resolves but carries no provider lease id or plugin id.
environments: {
getLeaseById: async () => ({ providerLeaseId: null, metadata: {} }),
@@ -616,12 +658,12 @@ describe("worker-bound live pseudo-terminal opener", () => {
openLivePtySession({ scope: SCOPE, environmentId: "env-1", leaseId: "lease-42" }),
).rejects.toMatchObject({ status: 503 });
// The opener never reached the worker manager.
expect(openSetupTokenPtySession).not.toHaveBeenCalled();
expect(openLoginPtySession).not.toHaveBeenCalled();
});
it("fails closed when the lease is missing", async () => {
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
workerManager: { openSetupTokenPtySession: vi.fn() },
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession: vi.fn() },
environments: { getLeaseById: async () => null },
});
@@ -706,7 +748,7 @@ function createStreamingSandbox() {
});
const writes: string[] = [];
const openPtySession: SetupTokenPtySessionOpener = async () => ({
const openPtySession: LoginPtySessionOpener = async () => ({
onData(next): void {
listener = next;
},
@@ -39,14 +39,21 @@ import type { environmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import type { Db } from "@paperclipai/db";
import {
createSetupTokenPtyTransport,
type SetupTokenPtySession,
type SetupTokenPtySessionOpener,
} from "@paperclipai/adapter-utils/setup-token-transport";
createLoginPtyTransport,
type LoginPtySession,
type LoginPtySessionOpener,
} from "@paperclipai/adapter-utils/login-pty-transport";
import {
runSetupTokenLogin,
CLAUDE_SETUP_TOKEN_COMMAND,
} from "@paperclipai/adapter-claude-local/server";
import { randomUUID } from "node:crypto";
import {
deriveLoginSessionHome,
resolveLoginCommandKey,
validateLoginSessionHome,
type LoginCommandKey,
} from "./login-command.js";
/**
* The sandbox provider for one login session. It acquires a fresh sandbox lease
@@ -64,7 +71,7 @@ export interface SetupTokenSandboxProvider {
acquire(input: {
scope: SetupTokenSessionScope;
deadline: number;
}): Promise<{ leaseId: string; openPtySession: SetupTokenPtySessionOpener }>;
}): Promise<{ leaseId: string; openPtySession: LoginPtySessionOpener }>;
/** Releases the lease by id. The service and the startup reaper call it. */
release(leaseId: string): Promise<void>;
}
@@ -246,7 +253,7 @@ export function buildSetupTokenLoginTransport(
// The service calls `leases.acquire` and then `factory` in sequence for one
// session, so a per-scope handoff correlates them. The per-owner session cap is
// one, so one scope holds at most one live acquire.
const pendingOpeners = new Map<string, { leaseId: string; openPtySession: SetupTokenPtySessionOpener }>();
const pendingOpeners = new Map<string, { leaseId: string; openPtySession: LoginPtySessionOpener }>();
// The reverse map releases a handoff by lease id when the factory never
// consumes it (a factory error path).
const leaseScopeKeys = new Map<string, string>();
@@ -288,7 +295,7 @@ export function buildSetupTokenLoginTransport(
pendingOpeners.delete(key);
leaseScopeKeys.delete(pending.leaseId);
const transport = createSetupTokenPtyTransport(pending.openPtySession);
const transport = createLoginPtyTransport(pending.openPtySession);
// Bridge the browser code. The service calls `submitCode` one time after the
// single `awaiting_code` transition wins. The runner calls `provideCode` one
@@ -358,7 +365,7 @@ export interface ProductionSetupTokenSandboxProviderDeps {
scope: SetupTokenSessionScope;
environmentId: string;
leaseId: string;
}) => Promise<SetupTokenPtySessionOpener>;
}) => Promise<LoginPtySessionOpener>;
/** A non-leaking status sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
@@ -535,21 +542,22 @@ export function createProductionSetupTokenCleanupStore(db: Db): SetupTokenCleanu
* reserves one route per worker, drives the open, binds the worker session
* identifier one time, routes output, and terminalizes the route.
*/
export interface SetupTokenPtyWorkerManagerLike {
openSetupTokenPtySession(
export interface LoginPtyWorkerManagerLike {
openLoginPtySession(
pluginId: string,
input: {
driverKey: string;
companyId: string;
environmentId: string;
providerLeaseId: string;
command: string;
loginCommandKey: LoginCommandKey;
sessionHome: string;
},
): Promise<SetupTokenPtySession>;
): Promise<LoginPtySession>;
}
/** The narrow lease-lookup surface the live opener needs. */
export interface SetupTokenPtyLeaseLookup {
export interface LoginPtyLeaseLookup {
getLeaseById(leaseId: string): Promise<
| {
providerLeaseId: string | null;
@@ -560,11 +568,11 @@ export interface SetupTokenPtyLeaseLookup {
}
/** The dependencies the worker-bound live pseudo-terminal opener needs. */
export interface WorkerBoundSetupTokenPtyOpenerDeps {
export interface WorkerBoundLoginPtyOpenerDeps {
/** The plugin worker manager that owns the host route gate. */
workerManager: SetupTokenPtyWorkerManagerLike;
workerManager: LoginPtyWorkerManagerLike;
/** The environment lease lookup that resolves the worker target for a lease. */
environments: SetupTokenPtyLeaseLookup;
environments: LoginPtyLeaseLookup;
/** A non-leaking status sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
@@ -579,9 +587,15 @@ function readLeaseMetaString(value: unknown): string | null {
* drives the worker through the manager route gate. The manager mints the host
* route identifier and owns the route lifecycle. The opener fails closed when the
* lease carries no sandbox worker binding.
*
* The opener resolves the host launch descriptor. It reads the closed login
* command key from the trusted adapter type, and it derives the server-controlled
* session home from a fresh UUID. It validates the home shape before the worker
* RPC. It never reads a command from the caller: the incoming opener argument is
* unused, so the transport cannot influence the sandbox command.
*/
export function createWorkerBoundSetupTokenPtyOpener(
deps: WorkerBoundSetupTokenPtyOpenerDeps,
export function createWorkerBoundLoginPtyOpener(
deps: WorkerBoundLoginPtyOpenerDeps,
): NonNullable<ProductionSetupTokenSandboxProviderDeps["openLivePtySession"]> {
const log = deps.log ?? (() => {});
return async ({ scope, environmentId, leaseId }) => {
@@ -601,13 +615,30 @@ export function createWorkerBoundSetupTokenPtyOpener(
log("[paperclip] Setup-token login: the lease carries no sandbox worker binding.");
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
}
return (command: string) =>
deps.workerManager.openSetupTokenPtySession(pluginId, {
// Resolve the closed command key from the trusted adapter type. An unmapped
// adapter fails closed before the worker RPC.
let loginCommandKey: LoginCommandKey;
try {
loginCommandKey = resolveLoginCommandKey(scope.adapterType);
} catch {
log("[paperclip] Setup-token login: the adapter type has no login command key.");
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
}
// The opener argument is the runner's fixed command string. It confers no
// command authority, so the opener ignores it and returns a host-bound opener.
return (_command: string) => {
// Generate the session UUID, then the home path, then validate the exact
// shape before the host sends the worker RPC.
const sessionHome = deriveLoginSessionHome(randomUUID());
validateLoginSessionHome(sessionHome);
return deps.workerManager.openLoginPtySession(pluginId, {
driverKey,
companyId: scope.companyId,
environmentId,
providerLeaseId,
command,
loginCommandKey,
sessionHome,
});
};
};
}
-1
View File
@@ -11,7 +11,6 @@ import { api } from "./client";
*/
export interface AdapterLoginProjection {
panelMode: "displayed_code" | "submitted_browser_code";
sandboxTransport: "streamed_exec" | "pseudo_terminal";
timeoutPolicy: "caller_bounded" | "fixed";
}
@@ -108,14 +108,15 @@ vi.mock("../adapters", () => ({
// third adapter with a projected login capability.
const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
new Map<string, { panelMode: string; timeoutPolicy: string }>([
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
// A third adapter, not a built-in, with a projected displayed-code login.
["vendor_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
// A non-built-in adapter with a pseudo-terminal login transport. The gate
// requires the provider pty capability from the transport, not the name.
["pty_vendor_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
["vendor_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
// A non-built-in adapter with a submitted-browser-code login. Every login
// runs on a real pseudo-terminal, so the gate requires the provider pty
// capability from the login capability, not the adapter name.
["pty_vendor_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
]),
);
@@ -408,9 +409,9 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
name: "Daytona",
driver: "sandbox",
config: { provider: "e2b" },
config: { provider: "daytona" },
}),
],
{ defaultEnvironmentId: "sandbox-1", ...agentOverrides },
@@ -419,16 +420,17 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
}
// A third adapter, not a built-in, in a sandbox environment. Its projected login
// capability drives the login affordance and the displayed-code panel.
// capability drives the login affordance and the displayed-code panel. The
// provider advertises the login pseudo-terminal capability the login needs.
async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
name: "Daytona",
driver: "sandbox",
config: { provider: "e2b" },
config: { provider: "daytona" },
}),
],
{ adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
@@ -1042,6 +1044,31 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(result.container, "Log in")).toBeTruthy();
});
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
// The Codex device login runs on a real pseudo-terminal, so it needs a
// provider that advertises the login pseudo-terminal capability. E2B reports
// no capability, so the panel stays hidden even after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
{ adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
// The adapter is not a built-in. Its projected login capability drives the
// login affordance and the panel, so the form reads the capability, not the
@@ -1199,9 +1226,9 @@ describe("AgentConfigForm environment selector", () => {
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
name: "Daytona",
driver: "sandbox",
config: { provider: "e2b" },
config: { provider: "daytona" },
}),
]);
+10 -12
View File
@@ -633,17 +633,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Load the sandbox provider capabilities. A login that runs on a real
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
// capability. The login panel gate reads this to hide the panel for a provider
// without the capability. Enable the query from the adapter login transport,
// not the adapter name: only a pseudo-terminal login consults this data. The
// gate is advisory; the server resolves the capability again and fails closed.
// without the capability. Enable the query when the adapter declares a login
// capability: every login runs on a real pseudo-terminal, so every login
// consults this data. The gate is advisory; the server resolves the capability
// again and fails closed.
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId
? queryKeys.environments.capabilities(selectedCompanyId)
: ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled:
Boolean(selectedCompanyId) &&
adapterCaps.login?.sandboxTransport === "pseudo_terminal",
enabled: Boolean(selectedCompanyId) && adapterCaps.login != null,
});
// When the instance forces Kubernetes execution, new agents must default to the
@@ -1035,11 +1034,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
testResult && testResultSupportsSandboxLogin
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
: null;
// A login that runs on a real pseudo-terminal needs a provider that advertises
// the login pseudo-terminal capability. Read the capability for the effective
// environment provider. The form reads the adapter login transport, not the
// adapter name: a login with a streamed-exec transport does not gate on this
// capability, so the requirement applies to a pseudo-terminal login only.
// A login runs on a real pseudo-terminal, so it needs a provider that
// advertises the login pseudo-terminal capability. Read the capability for the
// effective environment provider. The form reads the adapter login capability,
// not the adapter name: every adapter login gates on this provider capability.
const effectiveLoginProvider =
typeof effectiveLoginEnvironment?.config?.provider === "string"
? effectiveLoginEnvironment.config.provider
@@ -1047,7 +1045,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const providerSupportsLoginPty =
effectiveLoginProvider != null &&
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
const loginNeedsPty = adapterCaps.login?.sandboxTransport === "pseudo_terminal";
const loginNeedsPty = adapterCaps.login != null;
const showAdapterLogin =
adapterSupportsSandboxLogin &&
effectiveLoginEnvironment?.driver === "sandbox" &&
+3 -3
View File
@@ -82,9 +82,9 @@ vi.mock("../adapters/use-disabled-adapters", () => ({
// provider pty capability. Provide the projection so the login panel renders.
const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
new Map<string, { panelMode: string; timeoutPolicy: string }>([
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
]),
);