#13536·paperclip

acpx-engine: warm ACP handle reuse skips ensureSession, so per-run env (PAPERCLIP_API_KEY) never reaches a reused process

Author: lqduongdevCreated Sep 16, 2026Updated Sep 16, 2026

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 pass prepared.env into runtime.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_policy filtering in Codex config.toml — confirmed not filtering (inherit = "core", tested directly with a scrubbed parent env: all 31 PAPERCLIP_* vars including PAPERCLIP_API_KEY passed through).
  • Warm-handle idle-timer reuse — DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS = 0 (disabled by default) and our agent config does not set warmHandleIdleMs, so the per-entry idle timer isn't the mechanism. The remaining live path is same-sessionKey reuse 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

  1. Configure an agent (codex_local or claude_local) with a task/agent identity that yields the same sessionKey across two heartbeats (same company/agent/task).
  2. Trigger two overlapping runs for that agent/task (e.g. fire a second wake before the first run's ACP process is torn down).
  3. In the second run's shell, echo $PAPERCLIP_API_KEY — it is empty or belongs to the first run, not the second.
  4. Compare against emitSkippedStartupStep(ctx, "acp.handshake", ...) firing in the run log for the second run — confirms the warm-hit path was taken and ensureSession (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.