Files
PaperClipAI/packages/adapter-utils
Michel Tomas 5b15de9568 fix(adapter-utils): accept the bridge token in X-API-Key (#12785)
## 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>
2026-10-15 15:31:31 -07:00
..

@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) to remoteDir before the run starts. Runs with no git remote configured.
  • restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... }) — syncs the remote cwd back into localDir after the run, including any new commits the agent created. Also runs with no git remote configured.

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.