From b7b8fbf688c3568d984abb910513e5a509c0ddd3 Mon Sep 17 00:00:00 2001 From: Sergio-LPA Date: Thu, 13 Aug 2026 00:44:11 +0100 Subject: [PATCH] fix(adapter-utils): let explicit PAPERCLIP_API_URL override the derived runtime URL in run env (#10339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Every agent run gets a run-scoped bridge into the Paperclip API through the injected `PAPERCLIP_API_URL` / `PAPERCLIP_API_KEY` env vars, built by `buildPaperclipEnv` in `packages/adapter-utils/src/server-utils.ts` > - `buildPaperclipEnv` resolves that URL as `PAPERCLIP_RUNTIME_API_URL ?? PAPERCLIP_API_URL ?? http://:`, and the server always exports `PAPERCLIP_RUNTIME_API_URL` derived from `authPublicBaseUrl` at boot > - When `authPublicBaseUrl` points at an address that is not reachable from inside the runtime container (e.g. a VPN/tailnet-only address used to keep the web UI off the public internet), every local run receives a dead API URL (`curl` exit 7) and agents only survive by hand-rolling a localhost fallback > - An operator-set `PAPERCLIP_API_URL` is the documented escape hatch — `docs/deploy/environment-variables.md` states the server "preserves the value" when set externally and that the run-level var "inherits the server-level value" — but the run env builder inverts the precedence, so the override never actually reaches runs > - This pull request swaps the precedence in `buildPaperclipEnv` so an explicit `PAPERCLIP_API_URL` wins over the derived runtime URL, aligning the behavior with the documented contract > - The benefit is that operators with split-horizon topologies (public auth URL != container-reachable URL) can point agent runs at a reachable endpoint with one env var, with zero behavior change for deployments that do not set it ## Underlying Issue No pre-existing public issue covers this, so per CONTRIBUTING ("Link Issues or Describe Them In-PR") here are the `bug_report.yml` fields inline: - **What happened:** with `PAPERCLIP_AUTH_PUBLIC_BASE_URL` on a tailnet-only address and `PAPERCLIP_API_URL=http://localhost:3100` explicitly set in the server environment, every agent run still received `PAPERCLIP_API_URL=http://100.x.y.z:3100` (the derived, container-unreachable URL); `curl` from inside the run exits 7 and agents can only reach the API by hand-rolling a localhost fallback - **Expected behavior:** the run env inherits the operator-configured `PAPERCLIP_API_URL`, as documented in `docs/deploy/environment-variables.md` ("preserves the value", run-level var "inherits the server-level value") - **Steps to reproduce:** (1) set `PAPERCLIP_AUTH_PUBLIC_BASE_URL` to an address not reachable from inside the server container, (2) set `PAPERCLIP_API_URL=http://localhost:3100` in the server env, (3) trigger any agent run and inspect the spawned process env: it carries the derived URL, not the override - **Version/commit:** reproduced on the `91e58acb` image (2026-07-19); the precedence is unchanged on current `master` (`a3b293e`) - **Deployment mode:** single-host Docker Compose, local adapters (`claude_local`/`codex_local`), web UI exposed via VPN/tailnet only ## Related PRs (dedup search) Several in-flight PRs touch the same pain point (runs receiving an unreachable injected API URL) — linked for reviewer context; none of them honors the documented explicit override, and the older ones appear stale: - #9916 — reworks `PAPERCLIP_RUNTIME_API_URL` derivation and port preservation (server side); complementary, does not change run-env precedence - #8130 — honors a pre-set `PAPERCLIP_RUNTIME_API_URL` (server side); a complementary escape hatch via the runtime var instead of the documented `PAPERCLIP_API_URL` override - #8025 — heuristic: prefer loopback when the runtime bind is loopback (no activity since Jun 12) - #5692 — heuristic loopback-safe URL inside `buildPaperclipEnv` (no activity since May 14) - #4877 — broader same-host injection rework across 10 files (no activity since May 2) - #4794 — always forces loopback for spawned agents (no activity since Apr 30; would break split-horizon setups where a reachable non-loopback URL is intended) This PR intentionally takes the Path-1 route from CONTRIBUTING: the smallest possible change (swap two lines so the documented operator override wins) plus regression tests, rather than a new heuristic. ## What Changed - `packages/adapter-utils/src/server-utils.ts`: `buildPaperclipEnv` now resolves the injected URL as `PAPERCLIP_API_URL ?? PAPERCLIP_RUNTIME_API_URL ?? http://:` (explicit override first), with a short comment explaining why - `packages/adapter-utils/src/server-utils.test.ts`: three new tests covering the override precedence, the derived-URL fallback, and the listen-host default (including the `0.0.0.0` to `localhost` mapping) - `server/src/__tests__/paperclip-env.test.ts`: updated the expectation that encoded the old runtime-URL-first precedence and added the symmetric fallback case (runtime URL used when no explicit override is set) - No docs changes needed: `docs/deploy/environment-variables.md` already describes the fixed behavior ## Verification - `vitest run` on the new `buildPaperclipEnv` tests in `packages/adapter-utils`: 3/3 pass - `vitest run` on `server/src/__tests__/paperclip-env.test.ts` after the expectation update: 5/5 pass (the first CI run correctly flagged the one test that encoded the old precedence) - Reproduced and verified on a production deployment (single-host Docker, `PAPERCLIP_AUTH_PUBLIC_BASE_URL` on a tailnet-only address): - Before: freshly spawned runs received `PAPERCLIP_API_URL=http://100.x.y.z:3100` (verified in the spawned process `/proc//environ`); `curl` to it from inside the container exits 7 - After (with `PAPERCLIP_API_URL=http://localhost:3100` in the compose environment): a fresh run received `http://localhost:3100`, and `curl $PAPERCLIP_API_URL/api/agents/me` with the run-scoped key returned HTTP 200; the run finished `succeeded` with usage telemetry recorded ## Risks - Low. Behavior changes only for deployments that explicitly set `PAPERCLIP_API_URL`; when unset (the default), `PAPERCLIP_RUNTIME_API_URL` is used exactly as before - The sandbox callback bridge (`execution-target.ts`) is intentionally untouched: remote sandboxes genuinely need the publicly reachable URL, and its `input.hostApiUrl || PAPERCLIP_RUNTIME_API_URL || ...` chain still provides it ## Model Used - Claude Fable 5 (Anthropic, model ID `claude-fable-5`), extended thinking + agentic tool use via Claude Code, operating over SSH against the affected deployment ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Sergio-LPA <204395363+Sergio-LPA@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../adapter-utils/src/server-utils.test.ts | 56 +++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 5 +- server/src/__tests__/paperclip-env.test.ts | 13 ++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 81ec8458b5..1bfea41d35 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -10,6 +10,7 @@ import { buildPersistentSkillSnapshot, buildRuntimeMountedSkillSnapshot, buildInvocationEnvForLogs, + buildPaperclipEnv, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, materializePaperclipSkillCopy, refreshPaperclipWorkspaceEnvForExecution, @@ -2581,3 +2582,58 @@ describe("appendWithByteCap", () => { expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); }); }); + +describe("buildPaperclipEnv", () => { + const ENV_KEYS = [ + "PAPERCLIP_API_URL", + "PAPERCLIP_RUNTIME_API_URL", + "PAPERCLIP_LISTEN_HOST", + "PAPERCLIP_LISTEN_PORT", + "HOST", + "PORT", + ] as const; + + function withEnv(overrides: Record, fn: () => void) { + const saved = new Map(); + for (const key of ENV_KEYS) saved.set(key, process.env[key]); + try { + for (const key of ENV_KEYS) delete process.env[key]; + for (const [key, value] of Object.entries(overrides)) process.env[key] = value; + fn(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + it("prefers an explicit PAPERCLIP_API_URL override over the derived runtime URL", () => { + withEnv( + { + PAPERCLIP_API_URL: "http://localhost:3100", + PAPERCLIP_RUNTIME_API_URL: "http://203.0.113.7:3100", + }, + () => { + const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + expect(env.PAPERCLIP_API_URL).toBe("http://localhost:3100"); + expect(env.PAPERCLIP_AGENT_ID).toBe("agent-1"); + expect(env.PAPERCLIP_COMPANY_ID).toBe("company-1"); + }, + ); + }); + + it("falls back to the derived runtime URL when no explicit override is set", () => { + withEnv({ PAPERCLIP_RUNTIME_API_URL: "http://203.0.113.7:3100" }, () => { + const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + expect(env.PAPERCLIP_API_URL).toBe("http://203.0.113.7:3100"); + }); + }); + + it("derives a listen-host URL when neither override is set", () => { + withEnv({ PAPERCLIP_LISTEN_HOST: "0.0.0.0", PAPERCLIP_LISTEN_PORT: "3200" }, () => { + const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + expect(env.PAPERCLIP_API_URL).toBe("http://localhost:3200"); + }); + }); +}); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 7f4cf5cd70..80b9909b23 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1993,9 +1993,12 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", ); const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100"; + // An explicit PAPERCLIP_API_URL override must win over the URL derived from + // authPublicBaseUrl: the derived URL can be unreachable from inside the + // runtime container (e.g. when the public base URL is VPN/tailnet-only). const apiUrl = - process.env.PAPERCLIP_RUNTIME_API_URL ?? process.env.PAPERCLIP_API_URL ?? + process.env.PAPERCLIP_RUNTIME_API_URL ?? `http://${runtimeHost}:${runtimePort}`; vars.PAPERCLIP_API_URL = apiUrl; return vars; diff --git a/server/src/__tests__/paperclip-env.test.ts b/server/src/__tests__/paperclip-env.test.ts index e397f08242..e48c32fdc6 100644 --- a/server/src/__tests__/paperclip-env.test.ts +++ b/server/src/__tests__/paperclip-env.test.ts @@ -29,7 +29,7 @@ afterEach(() => { }); describe("buildPaperclipEnv", () => { - it("prefers an explicit PAPERCLIP_RUNTIME_API_URL", () => { + it("prefers an explicit PAPERCLIP_API_URL override over the derived runtime URL", () => { process.env.PAPERCLIP_RUNTIME_API_URL = "http://203.0.113.42:3102"; process.env.PAPERCLIP_API_URL = "http://localhost:4100"; process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1"; @@ -37,6 +37,17 @@ describe("buildPaperclipEnv", () => { const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + expect(env.PAPERCLIP_API_URL).toBe("http://localhost:4100"); + }); + + it("falls back to PAPERCLIP_RUNTIME_API_URL when no explicit override is set", () => { + process.env.PAPERCLIP_RUNTIME_API_URL = "http://203.0.113.42:3102"; + delete process.env.PAPERCLIP_API_URL; + process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1"; + process.env.PAPERCLIP_LISTEN_PORT = "3101"; + + const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + expect(env.PAPERCLIP_API_URL).toBe("http://203.0.113.42:3102"); });