diff --git a/cli/README.md b/cli/README.md index 0f67be46..8824b802 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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`. diff --git a/cli/src/configuration.ts b/cli/src/configuration.ts index db59cd20..a496a8dd 100644 --- a/cli/src/configuration.ts +++ b/cli/src/configuration.ts @@ -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 { + 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 { 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 {} diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 4635a7b2..6b4113a9 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -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']. diff --git a/cli/src/runner/controlClient.ts b/cli/src/runner/controlClient.ts index fa9daa1d..f231d8f7 100644 --- a/cli/src/runner/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -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 { @@ -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(), diff --git a/cli/src/runner/runnerIdentity.test.ts b/cli/src/runner/runnerIdentity.test.ts index 6c871b6d..4c3a664d 100644 --- a/cli/src/runner/runnerIdentity.test.ts +++ b/cli/src/runner/runnerIdentity.test.ts @@ -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( { diff --git a/cli/src/runner/runnerIdentity.ts b/cli/src/runner/runnerIdentity.ts index bb45c8a0..181c25aa 100644 --- a/cli/src/runner/runnerIdentity.ts +++ b/cli/src/runner/runnerIdentity.ts @@ -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> | 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 } diff --git a/cli/src/ui/doctor.test.ts b/cli/src/ui/doctor.test.ts new file mode 100644 index 00000000..e4b8972f --- /dev/null +++ b/cli/src/ui/doctor.test.ts @@ -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') + }) +}) diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index b0a6fb2c..4daae994 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -47,6 +47,14 @@ export function getEnvironmentInfo(): Record { }; } +export function redactSettingsForDisplay(settings: Record): Record { + 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 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:')); diff --git a/cli/src/ui/extraHeadersInit.ts b/cli/src/ui/extraHeadersInit.ts new file mode 100644 index 00000000..452281c0 --- /dev/null +++ b/cli/src/ui/extraHeadersInit.ts @@ -0,0 +1,17 @@ +import { configuration, normalizeExtraHeaders } from '@/configuration' +import { readSettings } from '@/persistence' + +export async function initializeExtraHeaders(): Promise { + 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') + ) +} diff --git a/cli/src/ui/tokenInit.test.ts b/cli/src/ui/tokenInit.test.ts new file mode 100644 index 00000000..6b29dcb3 --- /dev/null +++ b/cli/src/ui/tokenInit.test.ts @@ -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() + }) +}) diff --git a/cli/src/ui/tokenInit.ts b/cli/src/ui/tokenInit.ts index a3ac529a..6e9c967b 100644 --- a/cli/src/ui/tokenInit.ts +++ b/cli/src/ui/tokenInit.ts @@ -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 { // Initialize API URL first (env > settings.json > default) await initializeApiUrl() + await initializeExtraHeaders() // 1. Environment variable has highest priority (allows temporary override) if (configuration.cliApiToken) { diff --git a/docs/guide/installation.md b/docs/guide/installation.md index ae6e8ddc..b20e2ea6 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -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=..." + } } ``` diff --git a/docs/public/schemas/settings.schema.json b/docs/public/schemas/settings.schema.json index b1f5556b..0c7ec423 100644 --- a/docs/public/schemas/settings.schema.json +++ b/docs/public/schemas/settings.schema.json @@ -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",