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-api.ts b/src/client/machine-api.ts index fe92a4a88c..b27506daeb 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -23,7 +23,7 @@ export interface MachineStatusV1 { export interface MachineApiDeps { sync: typeof syncConnectedClient; disconnect: typeof disconnectClient; - scheduleStandaloneRecycle: () => void; + scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void; hubReachability?: () => HubReachability; setHubReachability?: (value: HubReachability) => void; } @@ -129,7 +129,7 @@ export async function handleMachineApi( } try { const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog }); - deps.scheduleStandaloneRecycle(); + deps.scheduleStandaloneRecycle(state.tokenFingerprint); return Response.json({ success: true, ...result }, { status: 202 }); } catch (error) { return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 }); diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index b7e54032b6..418c035182 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -70,8 +70,8 @@ export function startMachineListener( const machineApiDeps: MachineApiDeps = { sync: deps.machineApi?.sync ?? syncConnectedClient, disconnect: deps.machineApi?.disconnect ?? disconnectClient, - scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => { - void import("./runtime").then(module => module.scheduleStandaloneRecycle()); + scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => { + void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint)); }), hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability), setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }), diff --git a/src/client/runtime.ts b/src/client/runtime.ts index f8eb85920a..cff840ca41 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -4,6 +4,7 @@ import { loadConfig } from "../config"; import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state"; import { installCrashGuards } from "../lib/crash-guard"; import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { serviceApiTokenFingerprint } from "../lib/service-secrets"; import { findAvailablePort } from "../server/ports"; import { startMachineListener } from "./machine-listener"; import { readClientConnectionState } from "./state"; @@ -17,7 +18,20 @@ function cleanup(): void { removeRuntimePort(process.pid); } -export function scheduleStandaloneRecycle(): void { +export function standaloneRecycleEnv( + env: NodeJS.ProcessEnv, + disconnectedTokenFingerprint: string, +): NodeJS.ProcessEnv { + const childEnv = { ...env }; + const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); + if (admissionToken && serviceApiTokenFingerprint(admissionToken) === disconnectedTokenFingerprint) { + delete childEnv.OPENCODEX_API_AUTH_TOKEN; + delete childEnv.OCX_API_TOKEN_FILE; + } + return childEnv; +} + +export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void { if (recycleScheduled) return; recycleScheduled = true; const timer = setTimeout(() => { @@ -46,7 +60,7 @@ export function scheduleStandaloneRecycle(): void { detached: true, stdio: "ignore", windowsHide: true, - env: { ...process.env }, + env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), }); child.unref(); } diff --git a/tests/client-machine-listener.test.ts b/tests/client-machine-listener.test.ts index b4838718fe..c658b426e9 100644 --- a/tests/client-machine-listener.test.ts +++ b/tests/client-machine-listener.test.ts @@ -139,7 +139,9 @@ describe("client machine listener", () => { disconnected = true; return { restored: true, tokenRemoved: true, catalogRemoved: true, apiKeyId: "client-key-a" }; }, - scheduleStandaloneRecycle: () => { recycled = disconnected; }, + scheduleStandaloneRecycle: tokenFingerprint => { + recycled = disconnected && tokenFingerprint === connection().tokenFingerprint; + }, }, }); servers.push(server); diff --git a/tests/client-runtime.test.ts b/tests/client-runtime.test.ts new file mode 100644 index 0000000000..81bbf25324 --- /dev/null +++ b/tests/client-runtime.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { serviceApiTokenFingerprint } from "../src/lib/service-secrets"; +import { standaloneRecycleEnv } from "../src/client/runtime"; + +describe("standalone recycle environment", () => { + test("removes a disconnected hub token and its token-file source", () => { + const hubToken = "hub-issued-token"; + const source = { + OPENCODEX_API_AUTH_TOKEN: hubToken, + OCX_API_TOKEN_FILE: "/tmp/hub-service-token", + PATH: "/usr/bin", + }; + + expect(standaloneRecycleEnv(source, serviceApiTokenFingerprint(hubToken))).toEqual({ + PATH: "/usr/bin", + }); + expect(source.OPENCODEX_API_AUTH_TOKEN).toBe(hubToken); + }); + + test("preserves an independently configured operator credential", () => { + const operatorToken = "operator-token"; + const source = { + OPENCODEX_API_AUTH_TOKEN: operatorToken, + OCX_API_TOKEN_FILE: "/tmp/operator-token", + }; + + expect(standaloneRecycleEnv(source, serviceApiTokenFingerprint("disconnected-hub-token"))).toEqual(source); + }); +});