fix(cli): load extra headers from settings (#1041)

* test: reproduce issue #786

* fix: load extra headers from settings (closes #786)

* test: cover extra header precedence and redaction

* fix: redact persisted extra headers in diagnostics

* test: cover runner extra header identity

* fix: restart runner when extra headers change
This commit is contained in:
SSU-WEI HUANG
2026-07-16 12:27:50 +08:00
committed by GitHub
parent e87e875d72
commit c87720ab4d
14 changed files with 356 additions and 25 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ import { join } from 'node:path';
import { isBunCompiled, projectPath } from '@/projectPath';
import { isProcessAlive, isHapiRunnerProcess, killProcess } from '@/utils/process';
import { configuration } from '@/configuration';
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity';
import { hashRunnerCliApiToken, hashRunnerExtraHeaders, isRunnerStateCompatibleWithIdentity } from './runnerIdentity';
export function getInstalledCliMtimeMs(): number | undefined {
if (isBunCompiled()) {
@@ -219,7 +219,8 @@ export async function isRunnerRunningCurrentlyInstalledHappyVersion(): Promise<b
const currentIdentityMatches = isRunnerStateCompatibleWithIdentity(state, {
apiUrl: currentApiUrl,
machineId: currentMachineId,
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken)
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken),
extraHeadersHash: hashRunnerExtraHeaders(configuration.extraHeaders)
});
logger.debug(`[RUNNER CONTROL] Runner identity match: ${currentIdentityMatches}`, {
currentApiUrl,
+3 -1
View File
@@ -25,7 +25,7 @@ import { validateWorkspaceDirectory } from './validateWorkspaceDirectory';
import { join } from 'path';
import { buildMachineMetadata } from '@/agent/sessionFactory';
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
import { hashRunnerCliApiToken } from './runnerIdentity';
import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity';
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
@@ -744,6 +744,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
startedWithApiUrl: configuration.apiUrl,
startedWithMachineId: machineId,
startedWithCliApiTokenHash: hashRunnerCliApiToken(configuration.cliApiToken),
startedWithExtraHeadersHash: hashRunnerExtraHeaders(configuration.extraHeaders),
startedWithArgv,
startedWithVersionHandoffDisabled,
runnerLogPath: logger.logFilePath
@@ -1021,6 +1022,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
startedWithApiUrl: fileState.startedWithApiUrl,
startedWithMachineId: fileState.startedWithMachineId,
startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash,
startedWithExtraHeadersHash: fileState.startedWithExtraHeadersHash,
startedWithArgv,
startedWithVersionHandoffDisabled,
lastHeartbeat: new Date().toLocaleString(),
+112 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'
import {
hashRunnerCliApiToken,
hashRunnerExtraHeaders,
isRunnerStateCompatibleWithIdentity
} from './runnerIdentity'
describe('runnerIdentity', () => {
it('matches when api url, machine id, token hash all same', () => {
@@ -49,6 +53,113 @@ describe('runnerIdentity', () => {
)).toBe(false)
})
it('rejects reused runner when extra headers changed', () => {
expect(isRunnerStateCompatibleWithIdentity(
{
startedWithApiUrl: 'http://example.com',
startedWithMachineId: 'machine-123',
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token'),
startedWithExtraHeadersHash: 'old-headers-hash'
},
{
apiUrl: 'http://example.com',
machineId: 'machine-123',
cliApiTokenHash: hashRunnerCliApiToken('secret-token'),
extraHeadersHash: 'new-headers-hash'
}
)).toBe(false)
})
it('hashes equivalent extra headers identically regardless of insertion order', () => {
const first = hashRunnerExtraHeaders({
'X-Second': 'two',
'X-First': 'one'
})
const second = hashRunnerExtraHeaders({
'X-First': 'one',
'X-Second': 'two'
})
expect(first).toBe(second)
expect(first).toMatch(/^[a-f0-9]{64}$/)
})
it('matches equivalent extra headers with different insertion order', () => {
const first = hashRunnerExtraHeaders({
'X-Second': 'two',
'X-First': 'one'
})
const second = hashRunnerExtraHeaders({
'X-First': 'one',
'X-Second': 'two'
})
expect(isRunnerStateCompatibleWithIdentity(
{
startedWithApiUrl: 'http://example.com',
startedWithMachineId: 'machine-123',
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token'),
startedWithExtraHeadersHash: first
},
{
apiUrl: 'http://example.com',
machineId: 'machine-123',
cliApiTokenHash: hashRunnerCliApiToken('secret-token'),
extraHeadersHash: second
}
)).toBe(true)
})
it('keeps legacy runner state compatible when no extra headers are configured', () => {
expect(hashRunnerExtraHeaders({})).toBeUndefined()
expect(isRunnerStateCompatibleWithIdentity(
{
startedWithApiUrl: 'http://example.com',
startedWithMachineId: 'machine-123',
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token')
},
{
apiUrl: 'http://example.com',
machineId: 'machine-123',
cliApiTokenHash: hashRunnerCliApiToken('secret-token'),
extraHeadersHash: undefined
}
)).toBe(true)
})
it('rejects legacy runner state when extra headers are now configured', () => {
expect(isRunnerStateCompatibleWithIdentity(
{
startedWithApiUrl: 'http://example.com',
startedWithMachineId: 'machine-123',
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token')
},
{
apiUrl: 'http://example.com',
machineId: 'machine-123',
cliApiTokenHash: hashRunnerCliApiToken('secret-token'),
extraHeadersHash: 'configured-headers-hash'
}
)).toBe(false)
})
it('rejects runner state with headers when current headers are empty', () => {
expect(isRunnerStateCompatibleWithIdentity(
{
startedWithApiUrl: 'http://example.com',
startedWithMachineId: 'machine-123',
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token'),
startedWithExtraHeadersHash: 'configured-headers-hash'
},
{
apiUrl: 'http://example.com',
machineId: 'machine-123',
cliApiTokenHash: hashRunnerCliApiToken('secret-token'),
extraHeadersHash: undefined
}
)).toBe(false)
})
it('rejects reused runner when current machine id is missing', () => {
expect(isRunnerStateCompatibleWithIdentity(
{
+21 -1
View File
@@ -5,6 +5,7 @@ export type RunnerConnectionIdentity = {
apiUrl: string
machineId?: string
cliApiTokenHash?: string
extraHeadersHash?: string
}
export function hashRunnerCliApiToken(token: string | null | undefined): string | undefined {
@@ -15,10 +16,25 @@ export function hashRunnerCliApiToken(token: string | null | undefined): string
return createHash('sha256').update(trimmed).digest('hex')
}
export function hashRunnerExtraHeaders(
headers: Readonly<Record<string, string>> | null | undefined
): string | undefined {
const entries = Object.entries(headers ?? {})
if (entries.length === 0) {
return undefined
}
entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
return createHash('sha256').update(JSON.stringify(entries)).digest('hex')
}
export function isRunnerStateCompatibleWithIdentity(
state: Pick<
RunnerLocallyPersistedState,
'startedWithApiUrl' | 'startedWithMachineId' | 'startedWithCliApiTokenHash'
| 'startedWithApiUrl'
| 'startedWithMachineId'
| 'startedWithCliApiTokenHash'
| 'startedWithExtraHeadersHash'
>,
current: RunnerConnectionIdentity
): boolean {
@@ -34,5 +50,9 @@ export function isRunnerStateCompatibleWithIdentity(
return false
}
if (state.startedWithExtraHeadersHash !== current.extraHeadersHash) {
return false
}
return true
}