From efbdfc2c0c21496425ba129811b3a681c3634eed Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 14 Jun 2026 20:02:16 +0200 Subject: [PATCH 1/7] fix(server): honor pre-set PAPERCLIP_RUNTIME_API_URL At startup PAPERCLIP_RUNTIME_API_URL was overwritten unconditionally with the URL derived from the first allowedHostnames entry, conflating the internal agent->server callback URL with the public browser-facing hostname. Behind a reverse proxy/tunnel that forces agents to call the server over the public, access-gated origin instead of loopback. - Honor a pre-set PAPERCLIP_RUNTIME_API_URL via an extracted resolveRuntimeApiUrl() helper (pre-set-then-fallback, mirroring the PAPERCLIP_API_URL line above it). - Lead the runtime API candidates list (PAPERCLIP_RUNTIME_API_CANDIDATES_JSON) with a pre-set PAPERCLIP_RUNTIME_API_URL when present, so a pinned loopback callback isn't fronted by the public hostname an operator decoupled from. When it is unset the list still leads with the configured API URL, unchanged. - Unit-test the precedence in runtime-api.test.ts (pre-set wins, trimmed, unset/blank fall back) and the pinned-runtime candidate ordering in server-startup-feedback-export.test.ts. When neither var is pre-set, behavior is byte-for-byte identical to before. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/__tests__/runtime-api.test.ts | 39 +++++++++++++++++++ .../server-startup-feedback-export.test.ts | 21 ++++++++++ server/src/index.ts | 17 ++++++-- server/src/runtime-api.ts | 14 +++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/runtime-api.test.ts b/server/src/__tests__/runtime-api.test.ts index 5e46bbb06faf..62482647bb37 100644 --- a/server/src/__tests__/runtime-api.test.ts +++ b/server/src/__tests__/runtime-api.test.ts @@ -3,6 +3,7 @@ import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl, collectReachableInterfaceHosts, + resolveRuntimeApiUrl, } from "../runtime-api.js"; describe("runtime API discovery", () => { @@ -155,4 +156,42 @@ describe("runtime API discovery", () => { "fd7a:115c:a1e0::8a3a:a11d", ]); }); + + describe("resolveRuntimeApiUrl", () => { + it("honors a pre-set runtime API URL over the derived one", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: "http://127.0.0.1:3100", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://127.0.0.1:3100"); + }); + + it("trims a pre-set runtime API URL before honoring it", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: " http://127.0.0.1:3100 ", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://127.0.0.1:3100"); + }); + + it("falls back to the derived URL when the pre-set value is unset", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: undefined, + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://pc.example.com:3100"); + }); + + it("falls back to the derived URL when the pre-set value is blank", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: " ", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://pc.example.com:3100"); + }); + }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 91cc9fdd734e..98c5575f02f4 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -439,6 +439,12 @@ describe("startServer PAPERCLIP_API_URL handling", () => { loadConfigMock.mockReturnValue(buildTestConfig()); process.env.BETTER_AUTH_SECRET = "test-secret"; delete process.env.PAPERCLIP_API_URL; + // startServer() writes PAPERCLIP_RUNTIME_API_URL into process.env, and a + // pre-set value is now honored as the leading runtime candidate. Clear it + // (and the derived candidates) between tests so a prior startServer() call + // can't leak a runtime URL that overrides the PAPERCLIP_API_URL under test. + delete process.env.PAPERCLIP_RUNTIME_API_URL; + delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; }); afterEach(() => { @@ -474,6 +480,21 @@ describe("startServer PAPERCLIP_API_URL handling", () => { expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe("http://custom-api:3100"); }); + it("leads the runtime candidates with a pre-set PAPERCLIP_RUNTIME_API_URL", async () => { + process.env.PAPERCLIP_RUNTIME_API_URL = "http://127.0.0.1:9999"; + process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; + + await startServer(); + + // The pinned runtime URL is honored as the primary env var ... + expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("http://127.0.0.1:9999"); + // ... and leads the candidates list, so agents iterating candidates don't + // fall back onto the public API URL the operator decoupled from. + expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe( + "http://127.0.0.1:9999", + ); + }); + it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { const started = await startServer(); diff --git a/server/src/index.ts b/server/src/index.ts index 2755cc9177c3..d68fe340913e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -56,7 +56,7 @@ import { reconcileAdapterAvailability, } from "./services/adapter-registry-bootstrap.js"; import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js"; -import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js"; +import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl, resolveRuntimeApiUrl } from "./runtime-api.js"; import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; @@ -719,8 +719,19 @@ export async function startServer(): Promise { port: listenPort, }); const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl; + // A pre-set PAPERCLIP_RUNTIME_API_URL is the operator's deliberate runtime + // callback override (e.g. loopback behind a public tunnel). When present it + // wins both as the primary env var and as the leading candidate, so agents + // that iterate the candidates don't fall back onto the public hostname the + // operator decoupled from. When unset, candidates lead with the configured + // API URL exactly as before. + const presetRuntimeApiUrl = process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ?? ""; + const resolvedRuntimeApiUrl = resolveRuntimeApiUrl({ + presetRuntimeApiUrl, + derivedRuntimeApiUrl: runtimeApiUrl, + }); const runtimeApiCandidates = buildRuntimeApiCandidateUrls({ - preferredApiUrl: configuredApiUrl, + preferredApiUrl: presetRuntimeApiUrl || configuredApiUrl, authPublicBaseUrl: config.authPublicBaseUrl ?? null, allowedHostnames: config.allowedHostnames, bindHost: runtimeListenHost, @@ -728,7 +739,7 @@ export async function startServer(): Promise { }); process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); - process.env.PAPERCLIP_RUNTIME_API_URL = runtimeApiUrl; + process.env.PAPERCLIP_RUNTIME_API_URL = resolvedRuntimeApiUrl; process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify(runtimeApiCandidates); process.env.PAPERCLIP_API_URL = configuredApiUrl; diff --git a/server/src/runtime-api.ts b/server/src/runtime-api.ts index bf4caf36eb73..da19bf1bd4da 100644 --- a/server/src/runtime-api.ts +++ b/server/src/runtime-api.ts @@ -80,6 +80,20 @@ export function choosePrimaryRuntimeApiUrl(input: { return formatOrigin("http:", "localhost", input.port); } +/** + * Resolve the runtime API URL that agents call back on. A pre-set, non-blank + * `PAPERCLIP_RUNTIME_API_URL` wins over the derived value so operators can pin + * internal agent traffic to loopback while the dashboard serves a public host + * (e.g. behind a Cloudflare Tunnel). An unset or whitespace-only pre-set value + * falls back to the derived URL, preserving prior behavior. + */ +export function resolveRuntimeApiUrl(input: { + presetRuntimeApiUrl?: string | null; + derivedRuntimeApiUrl: string; +}): string { + return input.presetRuntimeApiUrl?.trim() || input.derivedRuntimeApiUrl; +} + export function collectReachableInterfaceHosts(input: { networkInterfacesMap?: NodeJS.Dict; } = {}): string[] { From 2f1e354b1e2ba00fd93a9474a563882aa98a580e Mon Sep 17 00:00:00 2001 From: Flop Date: Sun, 14 Jun 2026 16:02:27 +0200 Subject: [PATCH 2/7] gitignore: exclude MCP tooling working directories (#3) Local MCP artifacts (.playwright-mcp, .markdown_vault_mcp) should not be tracked; add ignore patterns and remove stray docs/.markdown_vault_mcp/. Co-authored-by: Soren Co-authored-by: Paperclip Co-authored-by: Cursor --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 63de6b12829b..df4f57838ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,8 @@ tests/storybook-visual/playwright-report/ .superpowers/ .claude/worktrees/ .herenow + +# MCP tooling working dirs (local artifacts — never commit) +.playwright-mcp/ +.markdown_vault_mcp/ +**/.markdown_vault_mcp/ From 6675c31bf4388b776369d9c19b521f08c620f6ab Mon Sep 17 00:00:00 2001 From: Flop Date: Sun, 14 Jun 2026 22:12:57 +0200 Subject: [PATCH 3/7] fix(ui): harden service worker + manifest crossorigin behind auth proxy (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind Cloudflare Access the console threw on every load: - A CORS error because /site.webmanifest was fetched without credentials and 302-redirected cross-origin to the Access login. - `TypeError: Failed to convert value to 'Response'` because the SW catch handler passed `caches.match(request)` (undefined on a miss) straight into `event.respondWith`. Changes: - SW now skips cross-origin requests and the auth-sensitive /site.webmanifest and /sw.js paths, letting the network/auth proxy own them untouched. - Never caches redirected / opaqueredirect responses (auth challenges). - Both catch branches await the cache lookup and fall back to a real Response (503 navigate, 504 otherwise) — respondWith never sees undefined. - Bump CACHE_NAME paperclip-v2 -> paperclip-v3 so the new worker replaces the cached old one. - index.html manifest link gains crossorigin="use-credentials". Co-authored-by: Wayland Co-authored-by: Claude Opus 4.8 --- ui/index.html | 2 +- ui/public/sw.js | 33 +++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/ui/index.html b/ui/index.html index 6a5781e711da..8c2cbaa6f54a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -15,7 +15,7 @@ - +