From f967bd9928af5917adc67489c3d74f65aea2b2a3 Mon Sep 17 00:00:00 2001 From: ROOOO Date: Wed, 18 Mar 2026 10:43:36 +0800 Subject: [PATCH] fix(runner): restart when hub identity changes (#303) * fix(runner): restart when hub identity changes * fix(runner): fail closed on missing identity --- cli/src/persistence.ts | 3 + cli/src/runner/controlClient.ts | 39 ++++++++++-- cli/src/runner/run.ts | 8 +++ cli/src/runner/runnerIdentity.test.ts | 90 +++++++++++++++++++++++++++ cli/src/runner/runnerIdentity.ts | 38 +++++++++++ 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 cli/src/runner/runnerIdentity.test.ts create mode 100644 cli/src/runner/runnerIdentity.ts diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 69ea7c0c..2a4f0986 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -35,6 +35,9 @@ export interface RunnerLocallyPersistedState { startTime: string; startedWithCliVersion: string; startedWithCliMtimeMs?: number; + startedWithApiUrl?: string; + startedWithMachineId?: string; + startedWithCliApiTokenHash?: string; lastHeartbeat?: string; runnerLogPath?: string; } diff --git a/cli/src/runner/controlClient.ts b/cli/src/runner/controlClient.ts index 1f013732..ca435814 100644 --- a/cli/src/runner/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -4,13 +4,15 @@ */ import { logger } from '@/ui/logger'; -import { clearRunnerState, readRunnerState } from '@/persistence'; +import { clearRunnerState, readRunnerState, readSettings } from '@/persistence'; import { Metadata } from '@/api/types'; import packageJson from '../../package.json'; import { existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { isBunCompiled, projectPath } from '@/projectPath'; import { isProcessAlive, killProcess } from '@/utils/process'; +import { configuration } from '@/configuration'; +import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'; export function getInstalledCliMtimeMs(): number | undefined { if (isBunCompiled()) { @@ -171,17 +173,44 @@ export async function isRunnerRunningCurrentlyInstalledHappyVersion(): Promise { // We don't have cleanup function at the time of server construction @@ -629,6 +631,9 @@ export async function startRunner(): Promise { startTime: new Date().toLocaleString(), startedWithCliVersion: packageJson.version, startedWithCliMtimeMs, + startedWithApiUrl: configuration.apiUrl, + startedWithMachineId: machineId, + startedWithCliApiTokenHash: hashRunnerCliApiToken(configuration.cliApiToken), runnerLogPath: logger.logFilePath }; writeRunnerState(fileState); @@ -788,6 +793,9 @@ export async function startRunner(): Promise { startTime: fileState.startTime, startedWithCliVersion: packageJson.version, startedWithCliMtimeMs, + startedWithApiUrl: fileState.startedWithApiUrl, + startedWithMachineId: fileState.startedWithMachineId, + startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash, lastHeartbeat: new Date().toLocaleString(), runnerLogPath: fileState.runnerLogPath }; diff --git a/cli/src/runner/runnerIdentity.test.ts b/cli/src/runner/runnerIdentity.test.ts new file mode 100644 index 00000000..6c871b6d --- /dev/null +++ b/cli/src/runner/runnerIdentity.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity' + +describe('runnerIdentity', () => { + it('matches when api url, machine id, token hash all same', () => { + const tokenHash = hashRunnerCliApiToken('secret-token') + + expect(isRunnerStateCompatibleWithIdentity( + { + startedWithApiUrl: 'http://example.com', + startedWithMachineId: 'machine-123', + startedWithCliApiTokenHash: tokenHash + }, + { + apiUrl: 'http://example.com', + machineId: 'machine-123', + cliApiTokenHash: tokenHash + } + )).toBe(true) + }) + + it('rejects reused runner when api url changed', () => { + expect(isRunnerStateCompatibleWithIdentity( + { + startedWithApiUrl: 'http://old-hub', + startedWithMachineId: 'machine-123', + startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token') + }, + { + apiUrl: 'http://new-hub', + machineId: 'machine-123', + cliApiTokenHash: hashRunnerCliApiToken('secret-token') + } + )).toBe(false) + }) + + it('rejects reused runner when token changed', () => { + expect(isRunnerStateCompatibleWithIdentity( + { + startedWithApiUrl: 'http://example.com', + startedWithMachineId: 'machine-123', + startedWithCliApiTokenHash: hashRunnerCliApiToken('old-token') + }, + { + apiUrl: 'http://example.com', + machineId: 'machine-123', + cliApiTokenHash: hashRunnerCliApiToken('new-token') + } + )).toBe(false) + }) + + it('rejects reused runner when current machine id is missing', () => { + expect(isRunnerStateCompatibleWithIdentity( + { + startedWithApiUrl: 'http://example.com', + startedWithMachineId: 'machine-123', + startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token') + }, + { + apiUrl: 'http://example.com', + cliApiTokenHash: hashRunnerCliApiToken('secret-token') + } + )).toBe(false) + }) + + it('rejects reused runner when current token hash is missing', () => { + expect(isRunnerStateCompatibleWithIdentity( + { + startedWithApiUrl: 'http://example.com', + startedWithMachineId: 'machine-123', + startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token') + }, + { + apiUrl: 'http://example.com', + machineId: 'machine-123' + } + )).toBe(false) + }) + + it('rejects old runner state missing connection identity', () => { + expect(isRunnerStateCompatibleWithIdentity( + {}, + { + apiUrl: 'http://example.com', + machineId: 'machine-123', + cliApiTokenHash: hashRunnerCliApiToken('secret-token') + } + )).toBe(false) + }) +}) diff --git a/cli/src/runner/runnerIdentity.ts b/cli/src/runner/runnerIdentity.ts new file mode 100644 index 00000000..bb45c8a0 --- /dev/null +++ b/cli/src/runner/runnerIdentity.ts @@ -0,0 +1,38 @@ +import { createHash } from 'node:crypto' +import type { RunnerLocallyPersistedState } from '@/persistence' + +export type RunnerConnectionIdentity = { + apiUrl: string + machineId?: string + cliApiTokenHash?: string +} + +export function hashRunnerCliApiToken(token: string | null | undefined): string | undefined { + const trimmed = token?.trim() + if (!trimmed) { + return undefined + } + return createHash('sha256').update(trimmed).digest('hex') +} + +export function isRunnerStateCompatibleWithIdentity( + state: Pick< + RunnerLocallyPersistedState, + 'startedWithApiUrl' | 'startedWithMachineId' | 'startedWithCliApiTokenHash' + >, + current: RunnerConnectionIdentity +): boolean { + if (!state.startedWithApiUrl || state.startedWithApiUrl !== current.apiUrl) { + return false + } + + if (!current.machineId || state.startedWithMachineId !== current.machineId) { + return false + } + + if (!current.cliApiTokenHash || state.startedWithCliApiTokenHash !== current.cliApiTokenHash) { + return false + } + + return true +}