fix(adapter-utils): let explicit PAPERCLIP_API_URL override the derived runtime URL in run env (#10339)

## 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://<listen-host>:<port>`, 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://<listen-host>:<port>` (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/<pid>/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 <noreply@anthropic.com>
This commit is contained in:
Sergio-LPA 2026-08-13 00:44:11 +01:00 committed by GitHub
parent 4660562fde
commit b7b8fbf688
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 72 additions and 2 deletions

View File

@ -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<string, string>, fn: () => void) {
const saved = new Map<string, string | undefined>();
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");
});
});
});

View File

@ -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;

View File

@ -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");
});