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:
parent
4660562fde
commit
b7b8fbf688
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue