The Stage-2 security re-review found two remaining defects in the in-sandbox duplex gateway. First, the gateway wrote every frame to process.stdout and ignored the boolean write() returns. A writable pipe returns false at its high-water mark, and Node then queues every later write in the stream's own buffer. A slow host reader let one admitted read retain its whole body's worth of encoded frames, not the one-frame margin the retained-peak comment claimed. Fix: route every outbound frame -- the heartbeat, the READY frame, the request envelope, and each body_chunk -- through one serialized writer. The writer issues at most one write at a time and waits for the stream to drain (or to error) before it starts the next queued line, so a slow reader can no longer make the gateway queue more than about one frame per active send. Second, readBody held the chunks array and the Buffer.concat result at the same time, so the transient peak was 2x the raw body while admission charged 1x. Fix: readBody now preallocates one buffer sized to the admission charge and copies each chunk into it in place, then returns a view over the written bytes. This removes the double retention; the tradeoff (a small body now retains the full charge-sized buffer for the life of the request) is documented in the function comment. Third, the admission reserve released inside a finally block on the response promise, so a response timeout could release the reserve while the outbound send still held the body. Fix: the reserve now releases only after both the response settled and the send finished (drained fully or ended by a stream error), tracked independently and released exactly once through an idempotent release function. The send now starts after the pending entry and its timer are registered, so a response timeout no longer stops or races the send. The retained-peak comment is rewritten to describe what the new code actually holds: one body buffer, plus at most one in-flight frame bounded by the frame size limit, times the admitted read count. Tests: a pipe-backpressure regression test proves the writer never queues more than about one bounded frame per active send under a stalled pipe; two isolated writer tests (extracted the same way the frame codec already is) prove the writer serializes writes and ends a stalled write on a stream error; an ordering test proves one request's chunks stay in sequence while a concurrent request interleaves its own; two reserve tests prove the reserve stays held through a stalled send and releases only once the send and the response both finish, for both a resumed pipe and a broken one. Kept unchanged: the 10 MiB + 1 body cap, the 64 MiB inflight bound, the host spill behavior, and the observable 413 / 503 / 502 statuses. Every existing test on the three touched files stays green (189 tests total). tsc is clean. Co-authored-by: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@paperclipai/adapter-utils
Shared utilities for Paperclip adapters: process spawning, environment injection, sandbox/SSH transport, workspace sync, and the round-trip helpers that move code between the local execution-workspace cwd and wherever the agent actually runs.
For the adapter-author guide see
docs/adapters/creating-an-adapter.md
and the in-repo notes at packages/adapters/AUTHORING.md.
No-remote-git contract
The local execution-workspace cwd is the only persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
Adapters that run the agent on a different host should use the SSH round-trip
helpers in src/ssh.ts:
prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })— bundles the local cwd (tracked files, dirty edits, untracked additions, and the git history needed to reconstruct it) toremoteDirbefore the run starts. Runs with nogit remoteconfigured.restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })— syncs the remote cwd back intolocalDirafter the run, including any new commits the agent created. Also runs with nogit remoteconfigured.
prepareRemoteManagedRuntime in
src/remote-managed-runtime.ts wraps both
calls for adapters that want a per-run remote workspace and an automatic
restoreWorkspace() finally hook.
The invariant is pinned by the no-remote-git contract case in
src/ssh-fixture.test.ts, which asserts that a
remote-only commit propagates to the local worktree through the
prepare → restore round-trip with no git remote configured at any point. Do
not regress that test.