From 142bcdd1c787ca328c130ece68353b0b81f7a171 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 18 Aug 2026 22:40:01 +0000 Subject: [PATCH] fix(workspaces): restore managed runtime origin hint Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 2 + ...ecution-workspaces-and-runtime-services.md | 30 ++++ .../managed-runtime-public-url.test.ts | 45 ++++++ .../src/__tests__/workspace-runtime.test.ts | 149 +++++++++++++++++- server/src/config.ts | 20 ++- server/src/services/workspace-runtime.ts | 111 ++++++++++++- 6 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/managed-runtime-public-url.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 2e38a077bf3..ce14abb4839 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -716,6 +716,8 @@ For Tailscale HTTPS exposure, readiness includes stable listener-ownership check In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, so listener containment does not break remote hot reload. +When a workspace service runs Paperclip for browser OAuth QA, configure its `expose.urlTemplate` with the canonical URL the browser can reach. Paperclip preserves explicit `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL` settings; otherwise it uses a valid exposed HTTPS origin (or loopback HTTP) as the managed runtime fallback for Better Auth and `/api/tools/oauth/callback`. Internal service names such as `http://paperclip-dev:` are rejected unless that hostname is genuinely the browser route. Use a unique origin per isolated worktree. See [Execution Workspaces And Runtime Services](../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#browser-reachable-origins-for-oauth-qa) for configuration and verification. + ## App-Shipped Skills Catalog The Paperclip app ships a curated catalog of company skills out of the box. The diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md index 6d4e9274004..ad27429bfdf 100644 --- a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -65,6 +65,36 @@ Heartbeat resolves a workspace for the run (code location and session continuity 4. Heartbeat passes the resolved code workspace to the agent run. 5. Heartbeat calls `ensureRuntimeServicesForRun` to start the workspace's `running`-desired runtime services, running the lazy runtime provision command first if one is configured and has not yet run (see "Lazy runtime provisioning" below). +## Browser-reachable origins for OAuth QA + +A managed service that runs Paperclip itself needs one canonical origin for Better Auth and tool OAuth callbacks. Paperclip resolves that origin in this order: + +1. Explicit service/runtime configuration such as `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL`. +2. An explicit instance auth public base URL. +3. The managed service's rendered `expose.urlTemplate`, injected as a low-priority runtime fallback. + +The exposed URL must describe the route the operator's browser actually uses. Non-loopback callbacks require HTTPS. Loopback HTTP such as `http://127.0.0.1:45439` is supported for local browser QA. Bind addresses, internal-only single-label names such as `paperclip-dev`, reserved/non-resolving names, and non-loopback HTTP origins fail service startup with configuration guidance instead of silently producing an unusable redirect URI. + +Keep readiness and browser exposure separate when a proxy or tailnet route fronts the process: + +```json +{ + "name": "paperclip-dev", + "command": "pnpm dev --bind lan", + "port": { "type": "auto" }, + "readiness": { + "type": "http", + "urlTemplate": "http://127.0.0.1:{{port}}" + }, + "expose": { + "type": "url", + "urlTemplate": "https://{{workspace.branchName}}.dev.example.com" + } +} +``` + +Use a distinct reachable hostname (or other distinct origin) per isolated worktree. Do not point multiple worktree runtimes at the parent instance's origin. After startup, open the service URL in the same browser session used for QA and verify `GET /api/tools/oauth/client-metadata`; its `redirect_uris` entry should use that service origin and `/api/tools/oauth/callback`. + ## Lazy runtime provisioning Some workspaces need heavy one-time setup — seeding a database, warming caches — before their runtime services can start. That work can be deferred to the first runtime-service start instead of running eagerly during workspace preparation. diff --git a/server/src/__tests__/managed-runtime-public-url.test.ts b/server/src/__tests__/managed-runtime-public-url.test.ts new file mode 100644 index 00000000000..6cfa8a056f0 --- /dev/null +++ b/server/src/__tests__/managed-runtime-public-url.test.ts @@ -0,0 +1,45 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config.js"; + +const missingConfigPath = path.join(os.tmpdir(), `paperclip-managed-runtime-config-${process.pid}.json`); + +function useIsolatedConfigEnvironment() { + vi.stubEnv("PAPERCLIP_CONFIG", missingConfigPath); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", ""); + vi.stubEnv("BETTER_AUTH_URL", ""); + vi.stubEnv("BETTER_AUTH_BASE_URL", ""); + vi.stubEnv("PAPERCLIP_AUTH_BASE_URL_MODE", ""); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "local_trusted"); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_EXPOSURE", "private"); + vi.stubEnv("PAPERCLIP_BIND", "loopback"); + vi.stubEnv("HOST", "127.0.0.1"); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("managed runtime public URL config", () => { + it("configures Better Auth from the managed runtime fallback", () => { + useIsolatedConfigEnvironment(); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://worktree.tail29c1aa.ts.net"); + + const config = loadConfig(); + + expect(config.authPublicBaseUrl).toBe("https://worktree.tail29c1aa.ts.net"); + expect(config.authBaseUrlMode).toBe("explicit"); + }); + + it("keeps explicit operator configuration ahead of the managed fallback", () => { + useIsolatedConfigEnvironment(); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://operator.example.com"); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://inferred.tail29c1aa.ts.net"); + + const config = loadConfig(); + + expect(config.authPublicBaseUrl).toBe("https://operator.example.com"); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 7f902c85de4..f5362ac43bb 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -37,6 +37,8 @@ import { refreshRemoteTrackingBaseRef, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + MANAGED_RUNTIME_PUBLIC_URL_ENV, + resolveManagedPaperclipRuntimePublicOrigin, resolveRuntimeProvisionCommand, resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, @@ -424,6 +426,8 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { DATABASE_URL: "postgres://example.test/paperclip", PAPERCLIP_HOME: "/tmp/paperclip-home", PAPERCLIP_INSTANCE_ID: "runtime-instance", + BETTER_AUTH_URL: "https://parent.example.test", + BETTER_AUTH_BASE_URL: "https://legacy-parent.example.test", npm_config_tailscale_auth: "true", npm_config_authenticated_private: "true", HOST: "0.0.0.0", @@ -431,6 +435,8 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { expect(sanitized.PAPERCLIP_HOME).toBeUndefined(); expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined(); + expect(sanitized.BETTER_AUTH_URL).toBeUndefined(); + expect(sanitized.BETTER_AUTH_BASE_URL).toBeUndefined(); expect(sanitized.DATABASE_URL).toBeUndefined(); expect(sanitized.npm_config_tailscale_auth).toBeUndefined(); expect(sanitized.npm_config_authenticated_private).toBeUndefined(); @@ -438,6 +444,53 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { }); }); +describe("resolveManagedPaperclipRuntimePublicOrigin", () => { + const baseInput = { + serviceName: "paperclip-dev", + command: "pnpm dev --bind lan", + }; + + it("leaves explicit operator origin configuration unchanged", () => { + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: { PAPERCLIP_PUBLIC_URL: "https://operator.example.com" }, + exposedUrl: "https://managed-worktree.example.com", + })).toBeNull(); + + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: { BETTER_AUTH_URL: "https://auth.example.com" }, + exposedUrl: "https://managed-worktree.example.com", + })).toBeNull(); + }); + + it("infers browser-reachable HTTPS and loopback origins", () => { + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "https://paperclip-dev.tail29c1aa.ts.net/path?ignored=true", + })).toBe("https://paperclip-dev.tail29c1aa.ts.net"); + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://127.0.0.1:45439", + })).toBe("http://127.0.0.1:45439"); + }); + + it("rejects internal-only and unsafe inferred origins with actionable guidance", () => { + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://paperclip-dev:45439", + })).toThrow(/internal-only.*Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL/); + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://10.0.0.8:45439", + })).toThrow(/non-loopback OAuth callbacks require HTTPS/); + }); +}); + describe("resolveRuntimeProvisionCommand", () => { it("backfills deferred seeding for legacy managed git worktrees", async () => { const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-")); @@ -4097,6 +4150,93 @@ describe("ensureRuntimeServicesForRun", () => { expect(services).toEqual([]); }); + it("injects isolated browser callback origins into separate worktree runtimes", async () => { + const firstRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-first-")); + const secondRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-second-")); + const firstWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(firstRoot), + source: "task_session", + strategy: "git_worktree", + branchName: "pap-17121-first", + worktreePath: firstRoot, + }; + const secondWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(secondRoot), + source: "task_session", + strategy: "git_worktree", + branchName: "pap-17121-second", + worktreePath: secondRoot, + }; + const serviceScript = + "const http=require('node:http');" + + "http.createServer((req,res)=>{" + + "if(req.url==='/api/health'){res.setHeader('content-type','application/json');" + + "res.end(JSON.stringify({status:'ok'}));return;}" + + `res.end(process.env.${MANAGED_RUNTIME_PUBLIC_URL_ENV}||'missing');` + + "}).listen(Number(process.env.PORT),'127.0.0.1');"; + const config = { + workspaceRuntime: { + services: [ + { + name: "paperclip-dev", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serviceScript)}`, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "https://{{workspace.branchName}}.tail29c1aa.ts.net", + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }, + ], + }, + }; + const actor = { id: "agent-1", name: "Codex Coder", companyId: "company-1" }; + + try { + const [first] = await startRuntimeServicesForWorkspaceControl({ + actor, + issue: null, + workspace: firstWorkspace, + executionWorkspaceId: "execution-workspace-first", + config, + adapterEnv: {}, + }); + const [second] = await startRuntimeServicesForWorkspaceControl({ + actor, + issue: null, + workspace: secondWorkspace, + executionWorkspaceId: "execution-workspace-second", + config, + adapterEnv: {}, + }); + + expect(first?.id).not.toBe(second?.id); + await expect(fetch(`http://127.0.0.1:${first!.port}/origin`).then((response) => response.text())) + .resolves.toBe("https://pap-17121-first.tail29c1aa.ts.net"); + await expect(fetch(`http://127.0.0.1:${second!.port}/origin`).then((response) => response.text())) + .resolves.toBe("https://pap-17121-second.tail29c1aa.ts.net"); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-first", + workspaceCwd: firstRoot, + }); + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-second", + workspaceCwd: secondRoot, + }); + await fs.rm(firstRoot, { recursive: true, force: true }); + await fs.rm(secondRoot, { recursive: true, force: true }); + } + }, 15_000); + it("requires Paperclip dev runtime services to pass /api/health readiness", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-health-")); const workspace = buildWorkspace(workspaceRoot); @@ -4229,7 +4369,7 @@ describe("ensureRuntimeServicesForRun", () => { } }); - it("uses explicit readiness URL when exposed URL is not the local probe address", async () => { + it("rejects an unreachable exposed origin even when readiness uses a local probe", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-explicit-readiness-")); const workspace = buildWorkspace(workspaceRoot); const runId = "run-paperclip-explicit-readiness"; @@ -4237,7 +4377,7 @@ describe("ensureRuntimeServicesForRun", () => { "node -e \"const http=require('node:http'); http.createServer((req,res)=>{ if (req.url==='/api/health') { res.end('ok'); return; } res.statusCode=404; res.end('not found'); }).listen(Number(process.env.PORT), '127.0.0.1')\""; try { - const services = await ensureRuntimeServicesForRun({ + await expect(ensureRuntimeServicesForRun({ runId, agent: { id: "agent-1", @@ -4273,10 +4413,7 @@ describe("ensureRuntimeServicesForRun", () => { }, }, adapterEnv: {}, - }); - - expect(services).toHaveLength(1); - expect(services[0]?.url).toMatch(/^http:\/\/not-a-real-paperclip-host\.invalid:\d+$/); + })).rejects.toThrow(/internal-only or non-resolvable.*Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL/); } finally { await releaseRuntimeServicesForRun(runId); } diff --git a/server/src/config.ts b/server/src/config.ts index 332b9d9c898..996b6e3cfb8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -198,17 +198,21 @@ export function loadConfig(): Config { ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) : null; const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL; - const authPublicBaseUrlRaw = - process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? - process.env.BETTER_AUTH_URL ?? - process.env.BETTER_AUTH_BASE_URL ?? - publicUrlFromEnv ?? - fileConfig?.auth?.publicBaseUrl; + const configuredAuthPublicBaseUrlRaw = [ + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL, + process.env.BETTER_AUTH_URL, + process.env.BETTER_AUTH_BASE_URL, + publicUrlFromEnv, + fileConfig?.auth?.publicBaseUrl, + ].find((value): value is string => typeof value === "string" && value.trim().length > 0); + const managedRuntimePublicUrl = process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() || undefined; + const authPublicBaseUrlRaw = configuredAuthPublicBaseUrlRaw ?? managedRuntimePublicUrl; const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; const authBaseUrlMode: AuthBaseUrlMode = authBaseUrlModeFromEnv ?? - fileConfig?.auth?.baseUrlMode ?? - (authPublicBaseUrl ? "explicit" : "auto"); + (configuredAuthPublicBaseUrlRaw === undefined && managedRuntimePublicUrl + ? "explicit" + : fileConfig?.auth?.baseUrlMode ?? (authPublicBaseUrl ? "explicit" : "auto")); const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP; const authDisableSignUp: boolean = disableSignUpFromEnv !== undefined diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index f305aec8946..e0cdc28cacc 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -673,6 +673,11 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ delete env[key]; } } + // These origin settings belong to the parent instance. Letting them leak into a + // managed worktree runtime can send auth cookies and OAuth callbacks to the wrong + // Paperclip instance. Runtime/service overrides are merged back after sanitizing. + delete env.BETTER_AUTH_URL; + delete env.BETTER_AUTH_BASE_URL; delete env.DATABASE_URL; delete env.npm_config_tailscale_auth; delete env.npm_config_authenticated_private; @@ -4859,6 +4864,94 @@ function isPaperclipDevRuntimeService(input: { serviceName?: string | null; comm ); } +export const MANAGED_RUNTIME_PUBLIC_URL_ENV = "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL"; + +const EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS = [ + "PAPERCLIP_PUBLIC_URL", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", +] as const; + +function isLoopbackRuntimeHostname(hostname: string) { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true; + if (net.isIP(normalized) !== 4) return false; + const firstOctet = Number(normalized.split(".")[0]); + return firstOctet === 127; +} + +function managedRuntimeOriginError(serviceName: string, reason: string) { + return new Error( + `Runtime service "${serviceName}" cannot derive a browser-reachable OAuth callback origin: ${reason}. ` + + "Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL for this service, or publish an HTTPS expose.urlTemplate " + + "that the operator's browser can reach (loopback HTTP is also supported).", + ); +} + +/** + * Resolve the low-priority public URL hint injected into a managed Paperclip dev + * service. Explicit operator origin settings are deliberately left untouched. + */ +export function resolveManagedPaperclipRuntimePublicOrigin(input: { + serviceName: string; + command: string; + environment: Record; + exposedUrl: string | null; +}) { + if (!isPaperclipDevRuntimeService(input)) return null; + if (EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS.some((key) => input.environment[key]?.trim())) return null; + if (!input.exposedUrl) { + throw managedRuntimeOriginError(input.serviceName, "the managed service does not report an exposed URL"); + } + + let parsed: URL; + try { + parsed = new URL(input.exposedUrl); + } catch { + throw managedRuntimeOriginError(input.serviceName, "the managed service reports an invalid exposed URL"); + } + + if (parsed.username || parsed.password) { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL contains credentials"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL must use HTTP or HTTPS"); + } + + const hostname = parsed.hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + const loopback = isLoopbackRuntimeHostname(hostname); + if (!hostname || hostname === "0.0.0.0" || hostname === "::") { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL uses a bind-only hostname"); + } + if ( + !loopback + && ( + (!hostname.includes(".") && net.isIP(hostname) === 0) + || hostname.endsWith(".invalid") + || hostname.endsWith(".test") + || hostname.endsWith(".internal") + || hostname.endsWith(".localdomain") + || hostname === "example.com" + || hostname.endsWith(".example.com") + || hostname === "example.net" + || hostname.endsWith(".example.net") + || hostname === "example.org" + || hostname.endsWith(".example.org") + ) + ) { + throw managedRuntimeOriginError( + input.serviceName, + `the exposed hostname "${hostname}" is internal-only or non-resolvable from a normal browser`, + ); + } + if (!loopback && parsed.protocol !== "https:") { + throw managedRuntimeOriginError(input.serviceName, "non-loopback OAuth callbacks require HTTPS"); + } + + return parsed.origin; +} + function resolveRuntimeServiceHealthUrl( url: string | null, input?: { serviceName?: string | null; command?: string | null }, @@ -5633,13 +5726,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P port === identityPort ? identity.serviceCwd : resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd); + const runtimeEnvOverrides: Record = { ...input.adapterEnv }; + for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { + runtimeEnvOverrides[key] = value; + } const env: Record = { ...sanitizeRuntimeServiceBaseEnv(process.env), - ...input.adapterEnv, + ...runtimeEnvOverrides, } as Record; - for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { - env[key] = value; - } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); @@ -5688,6 +5782,15 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P let url = exposureConfig ? null : backendUrl; const readinessUrlTemplate = asString(readiness.urlTemplate, ""); const readinessUrl = readinessUrlTemplate ? renderTemplate(readinessUrlTemplate, templateData) : null; + const managedRuntimePublicOrigin = resolveManagedPaperclipRuntimePublicOrigin({ + serviceName, + command, + environment: runtimeEnvOverrides, + exposedUrl: url, + }); + if (managedRuntimePublicOrigin) { + env[MANAGED_RUNTIME_PUBLIC_URL_ENV] = managedRuntimePublicOrigin; + } const stopPolicy = parseObject(input.service.stopPolicy); const serviceKey = createLocalServiceKey({ profileKind: "workspace-runtime",