diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a..261aece1d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index b7e54032b6..e23519b083 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -111,6 +111,14 @@ export function startMachineListener( if (managementPrincipal(req, managementAuth, config) !== "gui-session") { return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 }); } + // A loopback dashboard session proves possession, not user presence: any local + // process can fetch the dashboard bootstrap and replay its token and CSRF value. + // Keep the connected listener useful for status/diagnostics, but never let that + // credentialless bootstrap authorize durable machine changes. Those operations + // remain available through the explicit CLI commands. + if (req.method !== "GET" && req.method !== "HEAD") { + return Response.json({ error: "opencodex machine changes require the local CLI" }, { status: 403 }); + } return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req); } diff --git a/tests/client-machine-listener.test.ts b/tests/client-machine-listener.test.ts index b4838718fe..fef193fc1b 100644 --- a/tests/client-machine-listener.test.ts +++ b/tests/client-machine-listener.test.ts @@ -97,7 +97,7 @@ describe("client machine listener", () => { expect((await fetch(new URL("/api/machine/status", server.url), { method: "POST" })).status).toBe(404); }); - test("requires a GUI session for safe reads and Origin plus CSRF for mutations", async () => { + test("allows GUI-session reads but refuses mutations from a credentialless bootstrap", async () => { let syncCalls = 0; const server = startMachineListener(0, { state: connection(), @@ -124,11 +124,16 @@ describe("client machine listener", () => { expect((await fetch(syncUrl, { method: "POST", headers: safeHeaders, body: "{}" })).status).toBe(401); expect(syncCalls).toBe(0); const mutationHeaders = await guiHeaders(server, true); - expect((await fetch(syncUrl, { method: "POST", headers: mutationHeaders, body: "{}" })).status).toBe(200); - expect(syncCalls).toBe(1); + expect((await fetch(syncUrl, { method: "POST", headers: mutationHeaders, body: "{}" })).status).toBe(403); + expect((await fetch(new URL("/api/machine/shim", server.url), { + method: "POST", + headers: mutationHeaders, + body: JSON.stringify({ action: "uninstall" }), + })).status).toBe(403); + expect(syncCalls).toBe(0); }); - test("disconnect commits before 202 and schedules standalone recycle while the hub is offline", async () => { + test("does not let a bootstrapped GUI session disconnect or recycle the machine", async () => { let disconnected = false; let recycled = false; const server = startMachineListener(0, { @@ -148,9 +153,9 @@ describe("client machine listener", () => { headers: await guiHeaders(server, true), body: "{}", }); - expect(response.status).toBe(202); - expect(disconnected).toBe(true); - expect(recycled).toBe(true); + expect(response.status).toBe(403); + expect(disconnected).toBe(false); + expect(recycled).toBe(false); }); test("refuses startup without matching durable connected state", () => {