fix(workspaces): restore managed runtime origin hint

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 3d3a12a3e2
commit 142bcdd1c7
6 files changed
+339 -18

No files matched your search

+2
View File
@@ -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:<port>` 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
@@ -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.
@@ -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");
});
});
+143 -6
View File
@@ -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);
}
+12 -8
View File
@@ -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
+107 -4
View File
@@ -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<string, string>;
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<string, string> = { ...input.adapterEnv };
for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
runtimeEnvOverrides[key] = value;
}
const env: Record<string, string> = {
...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv,
...runtimeEnvOverrides,
} as Record<string, string>;
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",