diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a147b6a35b..a6ede01759 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -733,7 +733,9 @@ Lease recovery is bounded and explicit. Another issue may reclaim the lane once For Tailscale HTTPS exposure, readiness includes stable listener-ownership checks for every requested loopback port (the app and, when configured, its Vite HMR companion). Each listener must belong to the spawned managed process group; an unrelated listener that races onto either reserved port fails the start closed before the broker is asked to expose it. -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. +Managed `paperclip-dev` worktree services enable `PAPERCLIP_UI_DEV_MIDDLEWARE=true` by default, so newly started worktrees hot-reload UI source changes. Managed HTTPS services use this default only when they publish the Paperclip Vite HMR companion listener, which is the default exposure configuration. A service or adapter can explicitly set the variable to `false` when it intentionally needs to exercise the built UI bundle. + +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, and exposed runtimes use secure WebSockets, 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. diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index fdbeff3012..23e260b685 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -4401,6 +4401,59 @@ describe("ensureRuntimeServicesForRun", () => { expect(services).toEqual([]); }); + it("enables UI dev middleware by default for managed Paperclip worktree runtimes", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-ui-dev-")); + const workspace = buildWorkspace(workspaceRoot); + 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.PAPERCLIP_UI_DEV_MIDDLEWARE||'missing');" + + "}).listen(Number(process.env.PORT),'127.0.0.1');"; + + try { + const [runtime] = await startRuntimeServicesForWorkspaceControl({ + actor: { id: "agent-1", name: "Codex Coder", companyId: "company-1" }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-ui-dev", + 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: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }], + }, + }, + adapterEnv: {}, + }); + + await expect(fetch(`${runtime!.url}/ui-mode`).then((response) => response.text())) + .resolves.toBe("true"); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-ui-dev", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + 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-")); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 71222e50af..e96f793bac 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -6116,6 +6116,19 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P ...sanitizeRuntimeServiceBaseEnv(process.env), ...runtimeEnvOverrides, } as Record; + // Managed Paperclip worktrees are development environments, so their UI + // should track source edits without each project repeating this setting. + // An HTTPS profile must publish the companion HMR listener before it can use + // this default. Otherwise, leave the value unset so dev-runner keeps its + // built-UI safeguard. Keep every explicit service/adapter value. + const uiDevMiddlewareHasTransport = + !exposureConfig || exposureConfig.includePaperclipViteHmr; + if ( + uiDevMiddlewareHasTransport + && isPaperclipDevRuntimeService({ serviceName, command }) + ) { + env.PAPERCLIP_UI_DEV_MIDDLEWARE ??= "true"; + } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port);