fix(runner): restart when hub identity changes (#303)

* fix(runner): restart when hub identity changes

* fix(runner): fail closed on missing identity
This commit is contained in:
ROOOO
2026-03-18 10:43:36 +08:00
committed by GitHub
parent 2ecd56dbe8
commit f967bd9928
5 changed files with 173 additions and 5 deletions
+3
View File
@@ -35,6 +35,9 @@ export interface RunnerLocallyPersistedState {
startTime: string;
startedWithCliVersion: string;
startedWithCliMtimeMs?: number;
startedWithApiUrl?: string;
startedWithMachineId?: string;
startedWithCliApiTokenHash?: string;
lastHeartbeat?: string;
runnerLogPath?: string;
}
+34 -5
View File
@@ -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<b
logger.debug('[RUNNER CONTROL] No runner state found, returning false');
return false;
}
const settings = await readSettings();
const currentApiUrl = process.env.HAPI_API_URL
|| settings.apiUrl
|| settings.serverUrl
|| configuration.apiUrl;
const currentCliApiToken = process.env.CLI_API_TOKEN
|| settings.cliApiToken
|| configuration.cliApiToken;
const currentMachineId = settings.machineId;
try {
const currentCliMtimeMs = getInstalledCliMtimeMs();
if (typeof currentCliMtimeMs === 'number' && typeof state.startedWithCliMtimeMs === 'number') {
logger.debug(`[RUNNER CONTROL] Current CLI mtime: ${currentCliMtimeMs}, Runner started with mtime: ${state.startedWithCliMtimeMs}`);
return currentCliMtimeMs === state.startedWithCliMtimeMs;
if (currentCliMtimeMs !== state.startedWithCliMtimeMs) {
return false;
}
} else {
const currentCliVersion = packageJson.version;
logger.debug(`[RUNNER CONTROL] Current CLI version: ${currentCliVersion}, Runner started with version: ${state.startedWithCliVersion}`);
if (currentCliVersion !== state.startedWithCliVersion) {
return false;
}
}
const currentCliVersion = packageJson.version;
logger.debug(`[RUNNER CONTROL] Current CLI version: ${currentCliVersion}, Runner started with version: ${state.startedWithCliVersion}`);
return currentCliVersion === state.startedWithCliVersion;
const currentIdentityMatches = isRunnerStateCompatibleWithIdentity(state, {
apiUrl: currentApiUrl,
machineId: currentMachineId,
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken)
});
logger.debug(`[RUNNER CONTROL] Runner identity match: ${currentIdentityMatches}`, {
currentApiUrl,
currentMachineId,
runnerStartedWithApiUrl: state.startedWithApiUrl,
runnerStartedWithMachineId: state.startedWithMachineId
});
return currentIdentityMatches;
// PREVIOUS IMPLEMENTATION - Keeping this commented in case we need it
// Kirill does not understand how the upgrade of npm packages happen and whether
+8
View File
@@ -7,6 +7,7 @@ import { RunnerState, Metadata } from '@/api/types';
import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes';
import { logger } from '@/ui/logger';
import { authAndSetupMachineIfNeeded } from '@/ui/auth';
import { configuration } from '@/configuration';
import packageJson from '../../package.json';
import { getEnvironmentInfo } from '@/ui/doctor';
import { spawnHappyCLI } from '@/utils/spawnHappyCLI';
@@ -20,6 +21,7 @@ import { startRunnerControlServer } from './controlServer';
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
import { join } from 'path';
import { buildMachineMetadata } from '@/agent/sessionFactory';
import { hashRunnerCliApiToken } from './runnerIdentity';
export async function startRunner(): Promise<void> {
// We don't have cleanup function at the time of server construction
@@ -629,6 +631,9 @@ export async function startRunner(): Promise<void> {
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<void> {
startTime: fileState.startTime,
startedWithCliVersion: packageJson.version,
startedWithCliMtimeMs,
startedWithApiUrl: fileState.startedWithApiUrl,
startedWithMachineId: fileState.startedWithMachineId,
startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash,
lastHeartbeat: new Date().toLocaleString(),
runnerLogPath: fileState.runnerLogPath
};
+90
View File
@@ -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)
})
})
+38
View File
@@ -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
}