From 609664f5900bbf2f2b395b86dea8c1b38aeb8839 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 11:13:28 +0800 Subject: [PATCH] fix(platform): use a replacement runtime token on install resume After an install passed the setup-draft stage, `install --resume` kept the saved runtime token and ignored VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN. Workers the interrupted run had deployed also kept the old CF_API_TOKEN. - An explicit runtime token now wins over the saved one on every resume. A rejected saved token can be replaced at the prompt; cancelling keeps the saved token. - When the token differs from the saved one, it is sent to the deployed API and proxy Workers through the existing Worker secret update. A saved update left by a crash is finished on the next resume. - Only CF_API_TOKEN is sent. GitHub, R2 and email values keep their saved values and are not sent as updates. Fixes #666 Co-Authored-By: Claude Opus 5.5 --- docs/guide/platform/installation/setup.md | 2 + packages/void/src/cli/platform-lifecycle.ts | 45 ++++-- .../unit/platform-install-cutover.test.ts | 61 +++++++ .../test/unit/platform-setup-flow.test.ts | 149 +++++++++++++++++- 4 files changed, 244 insertions(+), 13 deletions(-) diff --git a/docs/guide/platform/installation/setup.md b/docs/guide/platform/installation/setup.md index 0bef60680..40dd8cf20 100644 --- a/docs/guide/platform/installation/setup.md +++ b/docs/guide/platform/installation/setup.md @@ -262,4 +262,6 @@ Resume restores your selected Hyperdrive. If that selection is missing from the If Cloudflare rejects a saved runtime token, Void opens the token page and asks for a replacement. For a missing [email permission](/guide/platform/installation/credentials#enable-email), update the existing token's permissions and resume. If you supplied a token through an environment variable, update that variable. +To replace the runtime token, for example after revoking it, resume with the new token in `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Void checks it, saves it, and updates the platform Workers that the interrupted run already deployed. + ::: diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 4687e93ee..4eec85089 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -933,8 +933,11 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr writePendingInstallationSecrets(setupManifest, values); } }; + // After the draft stage, a resume keeps the saved values. The runtime token + // is the exception: an explicit replacement wins, and is sent to Workers + // that are already deployed (see `updateExistingWorkers` below). const configured = (key: string, environment: string) => - credentialSetup + credentialSetup || key === 'cloudflareApiToken' ? process.env[environment] || values[key] || '' : values[key] || process.env[environment] || ''; const collect = async ( @@ -967,10 +970,9 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr openPlatformSetupPage(pages.runtime); } + const savedRuntimeToken = checkpoint?.cloudflareApiToken ?? ''; const usingSavedRuntimeToken = - credentialSetup && - !process.env.VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN && - Boolean(values.cloudflareApiToken); + !process.env.VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN && Boolean(values.cloudflareApiToken); let runtimeCloudflareApiToken = await collect( 'cloudflareApiToken', 'VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN', @@ -1004,7 +1006,6 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr } } catch (error) { if ( - credentialSetup && token === values.cloudflareApiToken && // The operator adds a missing email permission to the same token. !(error instanceof PlatformRuntimeTokenEmailPermissionError) && @@ -1014,7 +1015,11 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr ) ) { delete values.cloudflareApiToken; - writePendingInstallationSecrets(setupManifest, values); + // After the draft stage the installer saves the replacement, so a + // cancelled prompt keeps the token the deployed Workers still hold. + if (credentialSetup) { + writePendingInstallationSecrets(setupManifest, values); + } rejectedSavedRuntimeToken = true; } throw error; @@ -1151,22 +1156,38 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr : 'project encryption key', ].filter(Boolean) : []; + // A replaced runtime token must also reach the Workers already deployed, + // and a saved update must finish after a crash. Only the token is sent as + // an update: every other value comes from the checkpoint. Without a saved + // token (another machine) there is nothing to compare, so nothing is sent. + const replacingRuntimeToken = + !credentialSetup && !!savedRuntimeToken && runtimeCloudflareApiToken !== savedRuntimeToken; + const updateExistingWorkers = + replacingRuntimeToken || (!credentialSetup && Boolean(checkpoint?.workerSecretUpdatesJson)); + const checkpointValue = (value: string) => (updateExistingWorkers ? '' : value); const secrets = createPlatformInstallationSecrets({ configurableAuthentication: !!authenticationSelection.authentication.configurable, ...authenticationSecrets, bootstrapGitHubId, bootstrapGitHubLogin, - cloudflareApiToken: runtimeCloudflareApiToken, + cloudflareApiToken: replacingRuntimeToken + ? runtimeCloudflareApiToken + : checkpointValue(runtimeCloudflareApiToken), checkpoint: values, databaseUrl: installationDatabaseUrl, emailEnabled: plan.deployment.capabilities.email, - emailSigningSecret: process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || '', + // A saved email key wins on resume, so the variable would only add an update. + emailSigningSecret: + updateExistingWorkers && values.emailSigningSecret + ? '' + : process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || '', existingEmailEnabled: existing.deployment.capabilities.email, - githubClientId, - githubClientSecret, - r2AccessKeyId, - r2SecretAccessKey, + githubClientId: checkpointValue(githubClientId), + githubClientSecret: checkpointValue(githubClientSecret), + r2AccessKeyId: checkpointValue(r2AccessKeyId), + r2SecretAccessKey: checkpointValue(r2SecretAccessKey), existingInstallation: !credentialSetup, + updateExistingWorkers, generateIdentityKeys, jwtSecret: generateIdentityKeys ? configured('jwtSecret', 'VOID_PLATFORM_JWT_SECRET') diff --git a/packages/void/test/unit/platform-install-cutover.test.ts b/packages/void/test/unit/platform-install-cutover.test.ts index 3c70d2657..8bc9be0f4 100644 --- a/packages/void/test/unit/platform-install-cutover.test.ts +++ b/packages/void/test/unit/platform-install-cutover.test.ts @@ -26,6 +26,7 @@ import { createPlatformInstallPlan } from '../../src/cli/platform-plan.ts'; import { listInstallationManifests, readInstallationRecoverySecrets, + readPendingInstallationSecrets, } from '../../src/cli/platform-registry.ts'; import type { RunWrangler } from '../../src/cli/provision.ts'; @@ -99,6 +100,7 @@ async function createFakeCloudflare() { const r2 = new Map(); const named = new Map>(); const commands: Array<{ args: CloudflareOperation; worker: string | null }> = []; + const secretUploads: Array<{ secrets: Record; worker: string | null }> = []; let clock = Date.parse('2026-10-01T00:00:00.000Z'); let failBlockedCreate: string | null = null; let failUpload: string | null = null; @@ -147,6 +149,12 @@ async function createFakeCloudflare() { wrangler.run = async (args, opts) => { const worker = workerOf(args); commands.push({ args, worker }); + if ('secretsFilePath' in args && args.secretsFilePath) { + secretUploads.push({ + secrets: JSON.parse(readFileSync(args.secretsFilePath, 'utf8')), + worker, + }); + } if (args.type === 'worker.deploy') { const base = `${opts.apiBaseUrl}/accounts/${opts.accountId}/workers/scripts/${worker}`; if (failBlockedCreate === worker) { @@ -309,6 +317,7 @@ async function createFakeCloudflare() { }, kv, planningClient, + secretUploads, replaceActiveVersion: (name: string) => { const worker = workers.get(name)!; const active = worker.versions.get(worker.deployments[0]!.versions[0]!.version_id)!; @@ -727,6 +736,58 @@ it('enables traffic when resuming after the first Worker create was refused', as expect(workerStepKeys(resumed.manifest)).toContain('worker-cutover:2:committed'); }); +it('sends a replaced runtime token to Workers an interrupted install already deployed', async () => { + const cloud = await createFakeCloudflare(); + await expect( + runInstall(cloud, null, { + verifyHealth: async () => { + throw new Error('network unavailable'); + }, + }), + ).rejects.toThrow('network unavailable'); + const interrupted = currentManifest(); + expect(interrupted.status).toBe('failed'); + const { apiWorker, proxyWorker } = interrupted.deployment.resources; + // What `install --resume` builds: only an explicit runtime token is an update. + const resumeSecrets = (manifest: PlatformInstallationManifest, cloudflareApiToken: string) => + createPlatformInstallationSecrets({ + ...installationSecrets(), + checkpoint: readPendingInstallationSecrets(manifest), + cloudflareApiToken, + githubClientId: '', + githubClientSecret: '', + r2AccessKeyId: '', + r2SecretAccessKey: '', + existingInstallation: true, + updateExistingWorkers: true, + }); + + // The first resume stops after the API Worker received the replacement. + cloud.failUpload(proxyWorker); + const firstResume = cloud.secretUploads.length; + await expect( + runInstall(cloud, interrupted, { secrets: resumeSecrets(interrupted, 'replacement-token') }), + ).rejects.toThrow('upload failed'); + expect( + cloud.secretUploads.slice(firstResume).find((upload) => upload.worker === apiWorker)?.secrets, + ).toMatchObject({ CF_API_TOKEN: 'replacement-token' }); + const stopped = currentManifest(); + expect(readPendingInstallationSecrets(stopped)?.cloudflareApiToken).toBe('replacement-token'); + + // The next resume has no new token, yet still finishes the proxy update. + const secondResume = cloud.secretUploads.length; + const result = await runInstall(cloud, stopped, { secrets: resumeSecrets(stopped, '') }); + expect(result.manifest.status).toBe('installed'); + expect( + cloud.secretUploads.slice(secondResume).find((upload) => upload.worker === proxyWorker) + ?.secrets, + ).toEqual({ CF_API_TOKEN: 'replacement-token' }); + expect(readInstallationRecoverySecrets(result.manifest)).toMatchObject({ + cloudflareApiToken: 'replacement-token', + }); + expect(readInstallationRecoverySecrets(result.manifest)?.workerSecretUpdatesJson).toBeUndefined(); +}); + it('retries a restored enabling generation with every Worker restaged', async () => { const cloud = await createFakeCloudflare(); const plan = await planInstallation(cloud, null); diff --git a/packages/void/test/unit/platform-setup-flow.test.ts b/packages/void/test/unit/platform-setup-flow.test.ts index 0e1f82fb4..e6e234459 100644 --- a/packages/void/test/unit/platform-setup-flow.test.ts +++ b/packages/void/test/unit/platform-setup-flow.test.ts @@ -724,7 +724,10 @@ describe('platform credential onboarding', () => { ...readPendingInstallationSecrets(manifest), managementApiToken: 'saved-management-secret', }); - for (const [key, value] of Object.entries(environment)) { + // An explicit runtime token would replace the saved one. + const { VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN: _runtime, ...resumeEnvironment } = + environment; + for (const [key, value] of Object.entries(resumeEnvironment)) { vi.stubEnv(key, value); } const fetcher = vi.fn(async () => Response.json({ success: true, result: {} })); @@ -1417,6 +1420,150 @@ describe('platform credential onboarding', () => { projectSecretKey: environment.VOID_PLATFORM_PROJECT_SECRET_KEY, }); }); + describe('runtime token on an interrupted install resume', () => { + const replacement = 'replacement-runtime-token'; + const resume = (manifest: { id: string }) => + runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id]), + ); + const stubEnvironment = (runtimeToken: string) => { + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + vi.stubEnv('VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN', runtimeToken); + }; + + it('uses an explicit replacement and sends it to deployed Workers', async () => { + const manifest = await interruptedInstall(); + stubEnvironment(replacement); + await resume(manifest); + expect(mocks.password).not.toHaveBeenCalled(); + expect(mocks.install.mock.calls[0]![1]).toMatchObject({ + cloudflareApiToken: replacement, + githubClientId: environment.VOID_PLATFORM_GITHUB_CLIENT_ID, + r2SecretAccessKey: environment.VOID_PLATFORM_R2_SECRET_ACCESS_KEY, + }); + // Only the token is an update; the other values are not replacements. + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({ + CF_API_TOKEN: replacement, + }); + }); + + it.each(['', 'saved-runtime-token'])( + 'keeps deployed Workers as they are without a different token (env %j)', + async (runtimeToken) => { + const manifest = await interruptedInstall(); + stubEnvironment(runtimeToken); + await resume(manifest); + expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe('saved-runtime-token'); + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({}); + expect(mocks.install.mock.calls[0]![1].pendingWorkerSecretUpdateIntent).toBeUndefined(); + }, + ); + + it('sends no update when no token was saved to compare with', async () => { + const manifest = await interruptedInstall(); + // Another machine: the pending file holds no runtime token. + const { cloudflareApiToken: _saved, ...pending } = readPendingInstallationSecrets(manifest)!; + writePendingInstallationSecrets(manifest, pending); + stubEnvironment(replacement); + await resume(manifest); + expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe(replacement); + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({}); + }); + + it('finishes a saved token update after a crash', async () => { + const manifest = await interruptedInstall(); + const intent = { + baselineGeneration: 0, + generation: null, + id: 'intent-1', + schema: 1, + updates: { CF_API_TOKEN: replacement }, + }; + // The installer saved the replacement and its update before it stopped. + writePendingInstallationSecrets(manifest, { + ...readPendingInstallationSecrets(manifest), + cloudflareApiToken: replacement, + workerSecretUpdatesJson: JSON.stringify(intent), + }); + stubEnvironment(''); + await resume(manifest); + expect(mocks.install.mock.calls[0]![1]).toMatchObject({ + cloudflareApiToken: replacement, + pendingWorkerSecretUpdateIntent: intent, + }); + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({}); + }); + + it('does not turn a saved email key into an update', async () => { + const manifest = await interruptedInstall(); + const emailManifest = { + ...manifest, + deployment: { + ...manifest.deployment, + capabilities: { ...manifest.deployment.capabilities, email: true }, + }, + }; + writeInstallationManifest(emailManifest); + const emailSigningSecret = 'e'.repeat(32); + writePendingInstallationSecrets(emailManifest, { + ...readPendingInstallationSecrets(manifest), + emailSigningSecret, + }); + stubEnvironment(replacement); + vi.stubEnv('VOID_PLATFORM_EMAIL_SIGNING_SECRET', emailSigningSecret); + await resume(emailManifest); + expect(mocks.install.mock.calls[0]![1].emailSigningSecret).toBe(emailSigningSecret); + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({ + CF_API_TOKEN: replacement, + }); + }); + + it('asks for a replacement when Cloudflare rejects the saved token', async () => { + const manifest = await interruptedInstall(); + vi.spyOn(log, 'warn').mockImplementation(() => {}); + stubEnvironment(''); + mocks.preflight.mockRejectedValueOnce( + new PlatformCloudflareApiError('Authentication error', 403), + ); + mocks.password.mockResolvedValueOnce(replacement); + await resume(manifest); + expect(mocks.preflight).toHaveBeenCalledTimes(2); + expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe(replacement); + expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({ + CF_API_TOKEN: replacement, + }); + }); + + it('keeps the saved token when the replacement prompt is cancelled', async () => { + const manifest = await interruptedInstall(); + vi.spyOn(log, 'warn').mockImplementation(() => {}); + stubEnvironment(''); + mocks.preflight.mockRejectedValueOnce( + new PlatformCloudflareApiError('Authentication error', 401), + ); + mocks.password.mockResolvedValueOnce(Symbol('cancel')); + await expect(resume(manifest)).rejects.toThrow('Cancelled'); + expect(readPendingInstallationSecrets(manifest)?.cloudflareApiToken).toBe( + 'saved-runtime-token', + ); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it('fails without a prompt when Cloudflare rejects an explicit token', async () => { + const manifest = await interruptedInstall(); + stubEnvironment(replacement); + mocks.preflight.mockRejectedValueOnce( + new PlatformCloudflareApiError('Authentication error', 403), + ); + await expect(resume(manifest)).rejects.toThrow('Authentication error'); + expect(mocks.password).not.toHaveBeenCalled(); + expect(readPendingInstallationSecrets(manifest)?.cloudflareApiToken).toBe( + 'saved-runtime-token', + ); + }); + }); it('requires identity keys from the environment on a non-interactive install', async () => { for (const [key, value] of Object.entries(environment)) { if (key !== 'VOID_PLATFORM_JWT_SECRET' && key !== 'VOID_PLATFORM_PROJECT_SECRET_KEY') {