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
+1 -1
View File
@@ -100,7 +100,7 @@ See `src/configuration.ts` for all options.
- `HAPI_HOME` - Config/data directory (default: ~/.hapi).
- `HAPI_EXPERIMENTAL` - Enable experimental features (true/1/yes).
- `HAPI_EXTRA_HEADERS_JSON` - JSON object of extra headers to send on CLI → hub requests, e.g. `{"Cookie":"CF_Authorization=..."}`.
- `HAPI_EXTRA_HEADERS_JSON` - JSON object of extra headers to send on CLI → hub requests, e.g. `{"Cookie":"CF_Authorization=..."}`. Can also be set as the `extraHeaders` object in `~/.hapi/settings.json` (environment variable wins).
- `HAPI_CLAUDE_PATH` - Path to a specific `claude` executable.
- `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`.
+23 -15
View File
@@ -11,6 +11,28 @@ import { join } from 'node:path'
import packageJson from '../package.json'
import { getCliArgs } from '@/utils/cliArgs'
export function normalizeExtraHeaders(
value: unknown,
source: string,
warn: (message: string) => void = console.warn
): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
warn(`[WARN] ${source} must be a JSON object. Ignoring value.`)
return {}
}
const entries = Object.entries(value)
const headers = Object.fromEntries(
entries.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
if (Object.keys(headers).length !== entries.length) {
warn(`[WARN] ${source} only supports string header values. Ignoring non-string entries.`)
}
return headers
}
export function parseExtraHeaders(raw: string | undefined, warn: (message: string) => void = console.warn): Record<string, string> {
if (!raw) {
return {}
@@ -18,21 +40,7 @@ export function parseExtraHeaders(raw: string | undefined, warn: (message: strin
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
warn('[WARN] HAPI_EXTRA_HEADERS_JSON must be a JSON object. Ignoring value.')
return {}
}
const entries = Object.entries(parsed)
const headers = Object.fromEntries(
entries.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
)
if (Object.keys(headers).length !== entries.length) {
warn('[WARN] HAPI_EXTRA_HEADERS_JSON only supports string header values. Ignoring non-string entries.')
}
return headers
return normalizeExtraHeaders(parsed, 'HAPI_EXTRA_HEADERS_JSON', warn)
} catch {
warn('[WARN] Failed to parse HAPI_EXTRA_HEADERS_JSON. Ignoring value.')
return {}
+4
View File
@@ -19,6 +19,8 @@ interface Settings {
cliApiToken?: string
// API URL for server connections (priority: env HAPI_API_URL > this > default)
apiUrl?: string
// Extra headers for CLI -> hub requests (priority: env HAPI_EXTRA_HEADERS_JSON > this)
extraHeaders?: unknown
// Legacy field name (for migration, read-only)
serverUrl?: string
}
@@ -38,6 +40,8 @@ export interface RunnerLocallyPersistedState {
startedWithApiUrl?: string;
startedWithMachineId?: string;
startedWithCliApiTokenHash?: string;
// SHA-256 of canonicalized extra headers. Raw header values must never be persisted here.
startedWithExtraHeadersHash?: string;
/**
* Original process.argv.slice(2) of the runner process at start time, e.g.
* ['runner', 'start-sync', '--workspace-root', '/home/user/code'].
+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
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { redactSettingsForDisplay } from './doctor'
describe('redactSettingsForDisplay', () => {
it('redacts tokens and extra headers from diagnostic output', () => {
const displaySettings = redactSettingsForDisplay({
apiUrl: 'https://hapi.example.com',
cliApiToken: 'cli-secret',
extraHeaders: {
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'client-secret'
}
})
expect(displaySettings).toEqual({
apiUrl: 'https://hapi.example.com',
cliApiToken: '***',
extraHeaders: '***'
})
expect(JSON.stringify(displaySettings)).not.toContain('cli-secret')
expect(JSON.stringify(displaySettings)).not.toContain('client-secret')
})
})
+9 -2
View File
@@ -47,6 +47,14 @@ export function getEnvironmentInfo(): Record<string, any> {
};
}
export function redactSettingsForDisplay(settings: Record<string, unknown>): Record<string, unknown> {
return {
...settings,
cliApiToken: settings.cliApiToken ? '***' : undefined,
extraHeaders: settings.extraHeaders === undefined ? undefined : '***'
}
}
function getLogFiles(logDir: string): { file: string, path: string, modified: Date }[] {
if (!existsSync(logDir)) {
return [];
@@ -126,8 +134,7 @@ export async function runDoctorCommand(filter?: 'all' | 'runner'): Promise<void>
try {
settings = await readSettings();
console.log(chalk.bold('\n📄 Settings (settings.json):'));
// Hide cliApiToken in output for security
const displaySettings = { ...settings, cliApiToken: settings.cliApiToken ? '***' : undefined };
const displaySettings = redactSettingsForDisplay({ ...settings });
console.log(chalk.gray(JSON.stringify(displaySettings, null, 2)));
} catch (error) {
console.log(chalk.bold('\n📄 Settings:'));
+17
View File
@@ -0,0 +1,17 @@
import { configuration, normalizeExtraHeaders } from '@/configuration'
import { readSettings } from '@/persistence'
export async function initializeExtraHeaders(): Promise<void> {
if (process.env.HAPI_EXTRA_HEADERS_JSON !== undefined) {
return
}
const settings = await readSettings()
if (settings.extraHeaders === undefined) {
return
}
configuration._setExtraHeaders(
normalizeExtraHeaders(settings.extraHeaders, 'settings.json extraHeaders')
)
}
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { initializeApiUrlMock, readSettingsMock, updateSettingsMock } = vi.hoisted(() => ({
initializeApiUrlMock: vi.fn(async () => {}),
readSettingsMock: vi.fn(),
updateSettingsMock: vi.fn()
}))
vi.mock('@/ui/apiUrlInit', () => ({
initializeApiUrl: initializeApiUrlMock
}))
vi.mock('@/persistence', () => ({
readSettings: readSettingsMock,
updateSettings: updateSettingsMock
}))
import { configuration } from '@/configuration'
import { initializeToken } from './tokenInit'
describe('initializeToken extra headers', () => {
const originalExtraHeadersEnv = process.env.HAPI_EXTRA_HEADERS_JSON
beforeEach(() => {
delete process.env.HAPI_EXTRA_HEADERS_JSON
configuration._setCliApiToken('token-from-env')
configuration._setExtraHeaders({})
initializeApiUrlMock.mockClear()
readSettingsMock.mockReset()
updateSettingsMock.mockReset()
})
afterEach(() => {
if (originalExtraHeadersEnv === undefined) {
delete process.env.HAPI_EXTRA_HEADERS_JSON
} else {
process.env.HAPI_EXTRA_HEADERS_JSON = originalExtraHeadersEnv
}
configuration._setExtraHeaders({})
})
it('loads extra headers from settings even when the token is already initialized', async () => {
readSettingsMock.mockResolvedValue({
extraHeaders: {
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'client-secret'
}
})
await initializeToken()
expect(configuration.extraHeaders).toEqual({
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'client-secret'
})
})
it('loads both the token and extra headers from settings', async () => {
configuration._setCliApiToken('')
readSettingsMock.mockResolvedValue({
cliApiToken: 'token-from-settings',
extraHeaders: {
Cookie: 'CF_Authorization=from-settings'
}
})
await initializeToken()
expect(configuration.cliApiToken).toBe('token-from-settings')
expect(configuration.extraHeaders).toEqual({
Cookie: 'CF_Authorization=from-settings'
})
})
it('keeps environment extra headers instead of loading settings', async () => {
process.env.HAPI_EXTRA_HEADERS_JSON = '{"X-Source":"environment"}'
configuration._setExtraHeaders({ 'X-Source': 'environment' })
readSettingsMock.mockResolvedValue({
extraHeaders: { 'X-Source': 'settings' }
})
await initializeToken()
expect(configuration.extraHeaders).toEqual({ 'X-Source': 'environment' })
expect(readSettingsMock).not.toHaveBeenCalled()
})
it.each(['{}', '{not-json'])(
'does not fall back to settings when the environment value is %s',
async (environmentValue) => {
process.env.HAPI_EXTRA_HEADERS_JSON = environmentValue
configuration._setExtraHeaders({})
readSettingsMock.mockResolvedValue({
extraHeaders: { Cookie: 'CF_Authorization=from-settings' }
})
await initializeToken()
expect(configuration.extraHeaders).toEqual({})
expect(readSettingsMock).not.toHaveBeenCalled()
}
)
it('drops non-string settings header values without exposing them', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
readSettingsMock.mockResolvedValue({
extraHeaders: {
Cookie: 'CF_Authorization=valid',
'X-Numeric-Secret': 12345,
'X-Boolean-Secret': true
}
})
await initializeToken()
expect(configuration.extraHeaders).toEqual({
Cookie: 'CF_Authorization=valid'
})
expect(warnSpy).toHaveBeenCalledWith(
'[WARN] settings.json extraHeaders only supports string header values. Ignoring non-string entries.'
)
expect(JSON.stringify(warnSpy.mock.calls)).not.toContain('12345')
warnSpy.mockRestore()
})
})
+2
View File
@@ -13,6 +13,7 @@ import chalk from 'chalk'
import { configuration } from '@/configuration'
import { readSettings, updateSettings } from '@/persistence'
import { initializeApiUrl } from '@/ui/apiUrlInit'
import { initializeExtraHeaders } from '@/ui/extraHeadersInit'
/**
* Initialize CLI API token
@@ -21,6 +22,7 @@ import { initializeApiUrl } from '@/ui/apiUrlInit'
export async function initializeToken(): Promise<void> {
// Initialize API URL first (env > settings.json > default)
await initializeApiUrl()
await initializeExtraHeaders()
// 1. Environment variable has highest priority (allows temporary override)
if (configuration.cliApiToken) {
+6 -2
View File
@@ -177,7 +177,7 @@ On first run, HAPI:
|----------|---------|---------------|-------------|
| `CLI_API_TOKEN` | Auto-generated | `cliApiToken` | Shared secret for authentication |
| `HAPI_API_URL` | `http://localhost:3006` | `apiUrl` | Hub URL for CLI connections |
| `HAPI_EXTRA_HEADERS_JSON` | - | - | JSON object of extra outbound headers for CLI → hub HTTP/WebSocket requests |
| `HAPI_EXTRA_HEADERS_JSON` | - | `extraHeaders` | JSON object of extra outbound headers for CLI → hub HTTP/WebSocket requests |
| `HAPI_LISTEN_HOST` | `127.0.0.1` | `listenHost` | Hub HTTP bind address |
| `HAPI_LISTEN_PORT` | `3006` | `listenPort` | Hub HTTP port |
| `HAPI_PUBLIC_URL` | - | `publicUrl` | Public URL for external access |
@@ -198,13 +198,17 @@ On first run, HAPI:
Configuration priority: **ENV > settings.json > default**
When ENV values are set and not present in settings.json, they are automatically saved.
`HAPI_EXTRA_HEADERS_JSON` is not automatically saved, so access credentials are not persisted unexpectedly.
```json
{
"$schema": "https://hapi.run/docs/schemas/settings.schema.json",
"listenHost": "0.0.0.0",
"listenPort": 3006,
"publicUrl": "https://your-domain.com"
"publicUrl": "https://your-domain.com",
"extraHeaders": {
"Cookie": "CF_Authorization=..."
}
}
```
+7
View File
@@ -15,6 +15,13 @@
"default": "http://localhost:3006",
"description": "Hub URL for CLI connections. ENV: HAPI_API_URL"
},
"extraHeaders": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Extra headers for CLI to hub HTTP and WebSocket requests. ENV: HAPI_EXTRA_HEADERS_JSON"
},
"listenHost": {
"type": "string",
"default": "127.0.0.1",