## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An agent can run inside a sandbox. The sandbox callback bridge is the loopback gateway that lets the agent call the Paperclip API from inside that sandbox > - The gateway authenticates each local call with a per-run bridge token. It read that token from the `Authorization: Bearer <token>` header only > - The run hands the agent that token in `PAPERCLIP_API_KEY`, next to `PAPERCLIP_API_URL` (`execution-target.ts`). The token is named an API key, and the public Paperclip API documents that an API key goes in the `X-API-Key` header > - An agent that follows the documentation therefore sends `X-API-Key` first, and its first call fails with 401 `Invalid bridge token.` > - The agent then retries with a bearer header and the same token. The run still succeeds, but it wastes a turn and it can leave a confused agent trying unrelated repairs > - This pull request makes both generated gateways read the bridge token from `X-API-Key` when the request carries no bearer token > - The benefit is that a documented header convention stops producing a needless failure, at no cost to the token check ## Linked Issues or Issue Description No existing issue found. I searched the repository for open pull requests matching `x-api-key` and `bridge token`. Nothing addresses the in-sandbox gateway header convention. The closest neighbours are unrelated: #12632 (Composio `mcp.headers.x-api-key`) and #12120 (the HTTP/2 transport for this bridge, already merged). The problem follows `bug_report.yml`: **What happened?** An agent running in a sandbox calls the Paperclip API through the loopback callback bridge. When it sends the credential as `X-API-Key: <bridge token>`, the gateway answers `401` with `{"error":"Invalid bridge token."}`. The token in that header is the correct per-run bridge token. **Expected behavior** The gateway accepts the correct per-run bridge token in either header convention. The public API documents `X-API-Key`, so an agent that follows the documentation succeeds on its first call. **Steps to reproduce** 1. Start a run whose agent executes in a sandbox execution target, so the callback bridge is active. 2. The run exports `PAPERCLIP_API_URL` (the loopback bridge origin) and `PAPERCLIP_API_KEY` (the bridge token) into the sandbox environment. 3. From inside the sandbox, call the bridge with that key in the documented header: ```bash curl -i -H "X-API-Key: $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/agents/me" ``` 4. The response is `401 {"error":"Invalid bridge token."}`. 5. Repeat the same call with `-H "Authorization: Bearer $PAPERCLIP_API_KEY"`. The response is `200`. **Paperclip version or commit** Branched from `master` at `06c0e883f`. **Deployment mode** Self-hosted Kubernetes, with the agent in a sandbox execution target and a local model behind an OpenAI-compatible endpoint. **Agent adapter(s) involved** Not adapter-specific (core bug). Observed with a local-model adapter on a sandboxed lane. Any agent that follows the public API documentation sends this header. **Additional context** The variable name is the trigger. The run calls the credential `PAPERCLIP_API_KEY`, and an agent that has read the public API documentation knows an API key goes in `X-API-Key`. Paperclip's own in-prompt guidance uses `Authorization: Bearer $PAPERCLIP_API_KEY`, so the two conventions sit side by side. Across 12 observed runs on that lane, the agent's first bridge call used `X-API-Key` in 6 runs. Every one of those runs recovered by retrying with a bearer header and the same token. In some runs the agent first tried unrelated repairs, such as writing a test comment, before it changed the header. ## What Changed - Add one `readBridgeToken(req)` helper to the generated in-sandbox gateway source. The helper returns the bearer token from `Authorization` when the header carries one. It otherwise returns the `x-api-key` header value, or an empty string. - Use that helper in both generated gateway request handlers: the file-mode queue gateway and the HTTP/2 gateway. Both previously repeated the same two lines of bearer parsing. - The token still goes to the same `timingSafeEqual` comparison. The change does not alter the comparison, the token, or its lifetime. - Extend the file-mode round-trip test to cover an `X-API-Key` request with the right token, an `X-API-Key` request with a wrong token, and the fact that the header never reaches the host. - Add one HTTP/2-path test that covers the same three cases, and that proves a bearer token still wins when a request carries both headers. ## Verification Commands run on this branch: ``` node_modules/.bin/vitest run packages/adapter-utils/src/sandbox-callback-bridge.test.ts Test Files 1 passed (1) Tests 52 passed (52) cd packages/adapter-utils && ../../node_modules/.bin/tsc --noEmit 0 errors in the two files this pull request touches ``` Both new tests are real regression guards. With the test file in place and the source change reverted, each one fails with `expected 401 to be 200`: the file-mode round-trip test and the HTTP/2 test. Both pass with the source change applied. A reviewer can confirm the security properties by reading two things. First, `tokensMatch` in the generated source is unchanged, so the constant-time comparison against the same per-run token is unchanged. Second, `allowedHeaders` in the same source is a closed allowlist of `accept`, `content-type`, `if-match`, and `if-none-match`. `normalizeHeaders` drops every other header, so neither `authorization` nor `x-api-key` leaves the sandbox on either transport. The independent host-side check in `http2-bridge-server.ts` is untouched, because each gateway sets `authorization: Bearer <token>` on the request it forwards. **ROADMAP.md check:** I grepped `ROADMAP.md` for `bridge`, `x-api-key`, `redaction`, and `sandbox callback`. There were no matches. This is a small bug fix in an existing subsystem, so it does not overlap planned core work. **Duplicate search:** I searched pull requests in this repository for `x-api-key` and `bridge token`. No open or merged pull request changes how the in-sandbox gateway reads the token. ## Risks Low risk. - The change widens which header the gateway reads. It does not widen what the gateway accepts. A request still needs the exact per-run bridge token, and it still passes the same constant-time comparison. - A bearer token keeps priority. A request that carries both headers behaves exactly as it did before, which a test now pins. - A caller that presents a non-bearer `Authorization` header and a valid `X-API-Key` header now succeeds instead of failing. This is intentional: the bridge only ever issues bearer tokens, so no bridge caller relied on that failure. - The header allowlist already excluded `x-api-key`, so no forwarded request changes shape. - No migration, no configuration change, and no behavior change for a caller that already sends a bearer token. ## Model Used Claude Opus 5 (`claude-opus-5`), extended thinking at high effort — verify before merge. ## 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/...`, `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 — no documentation describes the bridge's header convention, so there is nothing to update - [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 --- <sub><em>**[@superbiche](https://github.com/superbiche)** · contributor · drafted with Claude Opus 5, reviewed before posting; the voice and decisions are mine.</em></sub>
@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.
Sandbox bridge deadlines
Command-managed bridge control operations (queue reads, input delivery, setup, and cleanup) have a host-enforced deadline of at most 30 seconds per shell command. A shorter configured timeout still applies. These operations cannot inherit the agent run's hours-long lifetime or wait forever on a provider that ignores its timeout. Long-lived agent session commands keep their own limits.
A control timeout reports a transport failure. It does not prove that the remote command stopped and does not replay an uncertain write. The process session closes failed input delivery and uses its existing shutdown path; normal execution settlement must still verify termination.
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.