diff --git a/tests/restart-chaos/journey-driver.ts b/tests/restart-chaos/journey-driver.ts index d11d111e968..3ea1a00f348 100644 --- a/tests/restart-chaos/journey-driver.ts +++ b/tests/restart-chaos/journey-driver.ts @@ -65,11 +65,10 @@ export function questionAnswer(interaction: Json, choice: string): { questionId: } /** The scripted Q3 agent: one connected-app call, then report the reviewed result. Adapted from tests/e2e/connection-reviews.spec.ts. */ -export function q3AgentScript(connectionId: string, title: string, serverBaseUrl: string): string { - // The wave's own loopback server. On a tailnet host, PAPERCLIP_API_URL names the machine's - // public URL, which reaches a different instance. +export function q3AgentScript(connectionId: string, title: string): string { + // The supervisor pins PAPERCLIP_API_URL to the wave's own loopback server. return ` -const base = ${JSON.stringify(serverBaseUrl)} + "/api"; +const base = process.env.PAPERCLIP_API_URL.replace(/\\/+$/, "").replace(/\\/api$/, "") + "/api"; const headers = { authorization: "Bearer " + process.env.PAPERCLIP_API_KEY, "content-type": "application/json", "x-paperclip-run-id": process.env.PAPERCLIP_RUN_ID }; const api = async (path, method = "GET", body) => { const response = await fetch(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); @@ -164,7 +163,7 @@ export class JourneyDriver { const agent = await api.post(`/api/companies/${lab.companyId}/agents`, { name: `Chaos pages agent ${plan.nonce}`, role: "engineer", title: "Connected-app journey agent", capabilities: "Creates one page with the chaos app after review.", adapterType: "process", - adapterConfig: { command: process.execPath, args: ["--input-type=module", "-e", q3AgentScript(state.connectionId, q3ToolArgument(plan.nonce), api.baseUrl)] }, + adapterConfig: { command: process.execPath, args: ["--input-type=module", "-e", q3AgentScript(state.connectionId, q3ToolArgument(plan.nonce))] }, }); state.agentId = String(agent.id); // A scripted process run records usage with no price, which the company's default budget diff --git a/tests/restart-chaos/journeys.test.ts b/tests/restart-chaos/journeys.test.ts index 86ab338dcad..a025933a49e 100644 --- a/tests/restart-chaos/journeys.test.ts +++ b/tests/restart-chaos/journeys.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { parseCommand } from "./config.js"; -import { actionRequestRows, JourneyDriver, questionAnswer, reviewClockNames, shiftReviewClockWith, type JourneyHost } from "./journey-driver.js"; +import { actionRequestRows, JourneyDriver, q3AgentScript, questionAnswer, reviewClockNames, shiftReviewClockWith, type JourneyHost } from "./journey-driver.js"; +import { waveServerUrlEnvironment } from "./supervisor.js"; +import { buildPaperclipServerEnvironment } from "../runner-e2e/harness-env.js"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { matrixCells, matrixMayLaunch, planMatrixWave } from "./journey-matrix.js"; import { pkceChallenge, startOAuthApp } from "./journey-oauth-app.js"; import { @@ -312,3 +318,37 @@ describe("Q3 review clock", () => { expect(connectionUpdate.params[2]).toBeNull(); }); }); + +describe("wave server URL", () => { + it("pins the wave's loopback origin over an ambient public API URL", () => { + const environment = buildPaperclipServerEnvironment( + { PAPERCLIP_API_URL: "https://dev-machine.example.ts.net", PATH: "/bin" }, + { PORT: "50187", ...waveServerUrlEnvironment(50187) }); + expect(environment.PAPERCLIP_API_URL).toBe("http://127.0.0.1:50187"); + }); + + it("sends the Q3 agent's run key only to the PAPERCLIP_API_URL its server assigned", async () => { + const requests: Array<{ url: string; authorization: string | undefined }> = []; + const server = createServer((request, response) => { + requests.push({ url: request.url ?? "", authorization: request.headers.authorization }); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(request.url?.endsWith("/interactions") + ? [{ status: "pending", payload: { toolAction: { tool: "create_page" } } }] : {})); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + const output = await new Promise((resolve, reject) => execFile(process.execPath, + ["--input-type=module", "-e", q3AgentScript("connection-1", "Chaos page")], + { env: { PATH: process.env.PATH, PAPERCLIP_API_URL: `${origin}/api/`, PAPERCLIP_API_KEY: "run-key", + PAPERCLIP_RUN_ID: "run-1", PAPERCLIP_TASK_ID: "issue-1" }, timeout: 20_000 }, + (error, stdout, stderr) => error ? reject(new Error(`${error.message}\n${stderr}`)) : resolve(stdout))); + expect(output).toContain("Waiting for the saved review"); + expect(requests.map(request => request.url)).toContain("/api/issues/issue-1/interactions"); + expect(requests.every(request => request.url.startsWith("/api/") && request.authorization === "Bearer run-key")).toBe(true); + } finally { + server.close(); + } + }); +}); diff --git a/tests/restart-chaos/supervisor.ts b/tests/restart-chaos/supervisor.ts index 370889a48b0..e3adf831f1b 100644 --- a/tests/restart-chaos/supervisor.ts +++ b/tests/restart-chaos/supervisor.ts @@ -61,6 +61,16 @@ export function crashLoopShouldFire(target: (typeof CRASH_LOOP_TARGETS)[number], return phase === "ready"; } +/** + * Agents reach the server at PAPERCLIP_API_URL. An explicit value wins over the URL the server + * derives from its public URL (a developer's .paperclip/.env or shell can name a tailnet origin + * that serves a different instance). Pin this wave's own loopback origin so an agent's key for + * the temporary instance is only ever sent to that instance. + */ +export function waveServerUrlEnvironment(port: number): { PAPERCLIP_API_URL: string } { + return { PAPERCLIP_API_URL: `http://127.0.0.1:${port}` }; +} + export interface SupervisorPaths { temporaryRoot: string; home: string; configPath: string; serverLogPath: string } /** @@ -218,6 +228,7 @@ export class ChaosSupervisor { // set values. A worktree-initialized checkout's file sets // PAPERCLIP_IN_WORKTREE=true, which suppresses all scheduling: no run starts. PAPERCLIP_IN_WORKTREE: "false", + ...waveServerUrlEnvironment(this.port), ...extraEnvironment, }; const environment = buildPaperclipServerEnvironment(source, overrides);