acpx-engine: warm ACP handle reuse skips ensureSession, so per-run env (PAPERCLIP_API_KEY) never reaches a reused process
Summary
PAPERCLIP_API_KEY (and any other per-run env, e.g. PAPERCLIP_RUN_ID) is dropped from the shell environment of a local ACP-engine agent process (codex_local and, structurally, claude_local) whenever a run reuses an already-running ACP process instead of cold-spawning one. The run-scoped env is only ever injected on the code path that performs a fresh handshake; the warm-reuse code path skips that entirely, so the reused process keeps whichever env (or lack of it) its original spawn happened to receive.
Where
Package: @paperclipai/adapter-utils (bundled in paperclipai CLI, verified on version 2026.831.1)
File: dist/acpx-engine/execute.js
- Line 2815:
let handle = cached?.handle ?? null; - Lines 2836 and 2866:
sessionOptions: { env: prepared.env },— these are the only two call sites that passprepared.envintoruntime.ensureSession(...). - Lines 2878–2888:
else { // Warm-handle hit: a compatible cached handle reuses the running ACP // agent, so the `acp.handshake` step does no work. Emit a step span and // event with `outcome = skipped` and a zero wall time, so the trace and // the run log show the skip as a distinct outcome, never a misleading // zero-work `ok` step. await emitSkippedStartupStep(ctx, "acp.handshake", { tracer: prepared.stepMetrics.tracer, parentContext: prepared.stepMetrics.parentContext, }); }
When handle = cached?.handle ?? null is non-null (a compatible cached handle exists for the run's sessionKey), the code takes the else branch and never calls runtime.ensureSession({ sessionOptions: { env: prepared.env } }). That call is the sole delivery path for the current run's env (see env-bindings.js / server-utils.js:buildPaperclipEnv, which always produces a non-empty PAPERCLIP_API_URL and a non-empty run-scoped PAPERCLIP_API_KEY) into the ACP subprocess.
Also relevant: renderPaperclipEnvNote (acpx-engine/execute.js, search The following PAPERCLIP_* environment variables are available in this run) generates its "these vars are available" note from prepared.env (the value that should have been injected), not from the actual process environment. On a warm-hit run this makes the prompt assert the vars are present while the live shell does not have them — very confusing to debug from the agent's own transcript.
Why this is a real gap, not a config error
sessionKey is built as paperclip:${companyId}:${agentId}:${taskKey}:${fingerprint} (execute.js:90-91). Two heartbeats for the same agent on the same task can overlap (e.g. a wake fires while a prior run for that task hasn't fully settled, or a long-running run is still holding the ACP handle when a second one starts). The second run borrows the still-live entry via hostStore.borrow(prepared.sessionKey) (execute.js:2729) and reuses cached.handle — which was established with the first run's prepared.env, including that first run's PAPERCLIP_API_KEY. If the key is genuinely run-scoped/short-lived (as documented for local adapters), the reused process is now running with a stale or entirely different run's key, or with whatever env state existed the first time the process ever spawned.
We ruled out the two most obvious suspects experimentally before finding this:
shell_environment_policyfiltering in Codexconfig.toml— confirmed not filtering (inherit = "core", tested directly with a scrubbed parent env: all 31PAPERCLIP_*vars includingPAPERCLIP_API_KEYpassed through).- Warm-handle idle-timer reuse —
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS = 0(disabled by default) and our agent config does not setwarmHandleIdleMs, so the per-entry idle timer isn't the mechanism. The remaining live path is same-sessionKeyreuse across overlapping runs, which is exactly what lines 2815/2878-2888 implement.
This affects DEFAULT_ACP_ENGINE_AGENT generically ("claude"), i.e. the same acpx-engine/execute.js code backs both codex_local and claude_local. We only observed the symptom on codex_local so far; claude_local is not structurally immune, it's just untested.
Suggested fix direction
On a warm-handle hit, still push the current run's env into the already-running ACP process (if the ACP protocol/runtime supports an explicit "update session env" call), or explicitly diff cached.env vs prepared.env and force a cold respawn when a credential-bearing var like PAPERCLIP_API_KEY changed, instead of silently keeping the stale/missing value. At minimum, renderPaperclipEnvNote should reflect what's actually live in the reused process, not prepared.env, so a debugging agent isn't told it has vars it doesn't have.
Repro sketch
- Configure an agent (
codex_localorclaude_local) with a task/agent identity that yields the samesessionKeyacross two heartbeats (same company/agent/task). - Trigger two overlapping runs for that agent/task (e.g. fire a second wake before the first run's ACP process is torn down).
- In the second run's shell,
echo $PAPERCLIP_API_KEY— it is empty or belongs to the first run, not the second. - Compare against
emitSkippedStartupStep(ctx, "acp.handshake", ...)firing in the run log for the second run — confirms the warm-hit path was taken andensureSession(and therefore env injection) was skipped.
Workaround in use today
The Paperclip MCP gateway carries its own bearer token per [mcp_servers.*] entry in config.toml, independent of shell env, so control-plane read/write still works on an affected run even when PAPERCLIP_API_KEY is missing from the shell. We're relying on that path until this is fixed upstream, since the fix belongs in vendored node_modules code (hand-patching would be overwritten on next upgrade).
This was isolated by comparing four real codex_local agent runs with identical session configuration where two dropped the key and two did not, then ruling out shell-policy filtering and warm-handle-idle-timer reuse by direct experiment before landing on the ensureSession skip above.
Source: paperclipai/paperclip