Files
PaperClipAI/server/src/__tests__/workspace-runtime-start-terminality.test.ts
T
Nicky LeachandPaperclip 10d2781a29 feat(sandbox): add the duplex bridge broker, gated transport selection, and fixed observability (#11769)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Sandbox adapters provide controlled execution for untrusted provider
environments.
> - The sandbox channel needs one persistent duplex transport with
strict host control.
> - The transport must remain off unless the instance setting and
provider capability both allow it.
> - The host must detect loss, bound resource use, and expose only safe
telemetry.
> - This pull request adds the broker, gated selection, kill-switch
wiring, fixed observability, and real-process proof.
> - The benefit is safer sandbox execution with bounded failure behavior
and inspectable transport results.

## Linked Issues or Issue Description

No public issue exists for this change. The related pull requests are
#11738 and #11750.

**Problem or motivation**

The sandbox duplex channel needs a host-controlled broker, strict
transport gates, bounded provider input, and safe loss telemetry.
Without these controls, a provider can cause replay, resource growth,
unsafe endpoint selection, or data exposure through telemetry.

**Proposed solution**

Add a host broker with nested time limits, request limits, one-shot
loss, and per-id deduplication. Select duplex transport only when the
instance setting and provider capability both equal true. Assign the
endpoint and nonce on the host. Reject invalid readiness data and use
the file bridge on failure. Add fixed redacted telemetry and a
real-process end-to-end test harness.

**Alternatives considered**

Keep the file bridge as the only transport. This avoids new channel
behavior but does not provide persistent duplex operation for supported
sandbox providers.

**Roadmap alignment**

This change supports the Cloud / Sandbox agents section in ROADMAP.md.

## What Changed

- Add the duplex bridge broker with bounded forward, response, and
gateway wait budgets.
- Bound concurrent requests, lifetime requests, and request-id bytes
before retention or forwarding.
- Select duplex transport only when both required gates are true.
- Assign the loopback port and nonce on the host and enforce a
liveness-only READY frame.
- Fall back to the file bridge after invalid readiness, contamination,
bind failure, or timeout.
- Carry the kill switch through the server, acpx engine, and six local
adapters.
- Add fixed, redacted duplex telemetry with a provider allowlist.
- Add a real-process end-to-end harness for readiness, round trips,
loss, and teardown.
- Add regression coverage for limits, loss, UTF-8 splits, concurrency,
and telemetry dimensions.

## Verification

- Adapter-utils, server, and Daytona typechecks pass locally.
- Adapter-utils tests pass, including the codec, broker,
execution-target sandbox, and real-process harness.
- Server kill-switch tests pass.
- Live Daytona tests pass with the required provider key and skip
without that key.
- The root pnpm-lock.yaml file has no diff.
- The branch contains ten commits after origin/master.

## Risks

- Duplex transport remains disabled unless both gates equal true.
- A provider remains an untrusted boundary and needs least-privilege
credentials and quotas.
- The server telemetry recorder stays deferred; the default recorder
does nothing.
- A provider that pre-binds the host port causes a fail-closed fallback
to the file bridge.
- The change adds no database migration and changes no root lockfile.

## Model Used

OpenAI GPT-5, exact model family GPT-5, large context window, reasoning,
and tool use. The model assisted with Git handoff validation and PR
preparation. The implementation commits came from the engineering
worktree.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with Fixes: # / Closes #
/ Refs # OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
- [x] My branch name describes the change (e.g. docs/... or fix/...) and
contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
- [x] I searched the GitHub PR list for similar PRs and confirmed this
is not a duplicate

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-22 09:01:31 -07:00

179 lines
7.9 KiB
TypeScript

import { setTimeout as delay } from "node:timers/promises";
import { afterEach, describe, expect, it } from "vitest";
import {
RUNTIME_SERVICE_READINESS_PROBE_TIMEOUT_MS,
allocateRuntimeServicePort,
claimRuntimeServiceBindPort,
resetRuntimeServicePortReservationsForTests,
waitForRuntimeServiceReadiness,
} from "../services/workspace-runtime.js";
/**
* Regression fixture for the adverse report on PAP-17207: two exclusive isolated workspaces
* asked for the same configured app/HMR pair at the same moment. One lane came up healthy,
* the loser was reallocated onto a port owned by an unrelated listener, and its managed start
* never reached a terminal state — leaving an active operation that rejected managed cleanup.
*
* The two mechanisms that produced the non-terminality are covered here:
* 1. a readiness probe against a socket that accepts but never answers, and
* 2. concurrent allocation handing the same port to two starts.
*/
describe("managed runtime start terminality", () => {
afterEach(() => {
resetRuntimeServicePortReservationsForTests();
});
const hangingService = {
readiness: { type: "http", timeoutSec: 1, intervalMs: 50 },
} as Record<string, unknown>;
it("fails a readiness check whose probes never answer instead of hanging forever", async () => {
let probes = 0;
const abortedProbes: string[] = [];
/** Accepts the request and only ever settles when the caller aborts, like a foreign listener. */
const neverAnsweringFetch = (async (_url: string | URL | Request, init?: RequestInit) => {
probes += 1;
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
abortedProbes.push(String(init?.signal?.reason ?? "aborted"));
reject(new Error("The operation was aborted due to timeout"));
});
});
}) as unknown as typeof fetch;
const startedAt = Date.now();
await expect(
waitForRuntimeServiceReadiness({
service: hangingService,
serviceName: "app",
command: "pnpm dev",
url: "http://127.0.0.1:45439/",
readinessUrl: null,
fetchImpl: neverAnsweringFetch,
}),
).rejects.toThrow(/Readiness check failed for http:\/\/127\.0\.0\.1:45439\//);
const elapsedMs = Date.now() - startedAt;
expect(probes).toBeGreaterThan(0);
expect(abortedProbes.length).toBeGreaterThan(0);
// Terminal well inside the 1s readiness budget plus one probe budget.
expect(elapsedMs).toBeLessThan(1_000 + RUNTIME_SERVICE_READINESS_PROBE_TIMEOUT_MS);
});
it("negative control: an unbounded probe loop never terminalizes the same start", async () => {
// This is the shape of the code that shipped: `fetch` with no abort signal, so the
// deadline at the top of the loop is never re-evaluated once a probe parks.
const legacyWait = async () => {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
await new Promise<never>(() => {});
await delay(50);
}
throw new Error("Readiness check failed");
};
const settled = await Promise.race([
legacyWait().then(() => "settled").catch(() => "settled"),
delay(1_500).then(() => "still-hanging"),
]);
expect(settled).toBe("still-hanging");
});
it("stops probing as soon as the readiness budget is spent", async () => {
let probes = 0;
const refusingFetch = (async () => {
probes += 1;
throw new Error("connect ECONNREFUSED 127.0.0.1:45439");
}) as unknown as typeof fetch;
await expect(
waitForRuntimeServiceReadiness({
service: { readiness: { type: "http", timeoutSec: 1, intervalMs: 200 } },
url: "http://127.0.0.1:45439/",
readinessUrl: null,
fetchImpl: refusingFetch,
}),
).rejects.toThrow(/ECONNREFUSED/);
// ~1s budget at a 200ms interval: a bounded handful of probes, never an unbounded spin.
expect(probes).toBeGreaterThan(1);
expect(probes).toBeLessThan(12);
});
it("hands two concurrent starts distinct ports even when the kernel repeats a candidate", async () => {
// The kernel really does hand the same ephemeral port to two `listen(0)` probes once the
// first probe socket has closed, which is exactly the reallocation collision in the report.
const candidates = [49871, 49871, 49871, 49872];
let index = 0;
const probe = async () => candidates[Math.min(index++, candidates.length - 1)]!;
const portOwnerLookup = async () => null;
const [first, second] = await Promise.all([
allocateRuntimeServicePort({ probe, portOwnerLookup }),
allocateRuntimeServicePort({ probe, portOwnerLookup }),
]);
expect(first).not.toBe(second);
expect(new Set([first, second])).toEqual(new Set([49871, 49872]));
});
it("skips a candidate port that a live process already owns", async () => {
const candidates = [49881, 49882];
let index = 0;
const probe = async () => candidates[Math.min(index++, candidates.length - 1)]!;
const portOwnerLookup = async (port: number) => (port === 49881 ? 4242 : null);
await expect(allocateRuntimeServicePort({ probe, portOwnerLookup })).resolves.toBe(49882);
// The rejected candidate must not stay reserved, or later starts would leak allocations.
await expect(
allocateRuntimeServicePort({ probe: async () => 49881, portOwnerLookup: async () => null }),
).resolves.toBe(49881);
});
it("skips a candidate port inside the runtime exposure app-port range", async () => {
// The kernel can hand an ephemeral port inside the exposure app-port range
// (42000-42999). The reconciler classifies a persisted row by its port: a
// port in that range marks the row as an exposure reservation, not a managed
// auto port. So the allocator must never return an in-range port; it drops
// the in-range candidate and takes the next out-of-range one.
const candidates = [42500, 49883];
let index = 0;
const probe = async () => candidates[Math.min(index++, candidates.length - 1)]!;
const portOwnerLookup = async () => null;
await expect(allocateRuntimeServicePort({ probe, portOwnerLookup })).resolves.toBe(49883);
// The allocator skipped 42500 before it reserved a candidate, so the in-range
// port stays free. A later start can still claim it through the exposure path.
expect(claimRuntimeServiceBindPort(42500, null)).toBe(true);
});
it("refuses a configured port a sibling start is already claiming", () => {
// The reported collision was on a *configured* app/HMR pair, not an auto-allocated one:
// both lanes read the pair as free because neither had bound or persisted a row yet.
expect(claimRuntimeServiceBindPort(42003, null)).toBe(true);
expect(claimRuntimeServiceBindPort(42003, null)).toBe(false);
// The HMR companion is claimed independently, so a lane can lose on either port.
expect(claimRuntimeServiceBindPort(42004, null)).toBe(true);
expect(claimRuntimeServiceBindPort(42004, null)).toBe(false);
});
it("never lets a start be refused by its own allocation", async () => {
// An auto-allocated port arrives already reserved by this start, and its identity port can
// resolve to that same value. Treating that as a sibling's claim would fail the start on its
// own reservation.
const held = await allocateRuntimeServicePort({
probe: async () => 49901,
portOwnerLookup: async () => null,
});
expect(held).toBe(49901);
expect(claimRuntimeServiceBindPort(49901, held)).toBe(true);
// A different start still loses against that held reservation.
expect(claimRuntimeServiceBindPort(49901, null)).toBe(false);
});
it("fails terminally rather than looping when no free port can be found", async () => {
await expect(
allocateRuntimeServicePort({ probe: async () => 49891, portOwnerLookup: async () => 99 }),
).rejects.toThrow(/Could not allocate a free loopback port/);
});
});