mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+1
-1
@@ -100,7 +100,7 @@ See `src/configuration.ts` for all options.
|
|||||||
|
|
||||||
- `HAPI_HOME` - Config/data directory (default: ~/.hapi).
|
- `HAPI_HOME` - Config/data directory (default: ~/.hapi).
|
||||||
- `HAPI_EXPERIMENTAL` - Enable experimental features (true/1/yes).
|
- `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_CLAUDE_PATH` - Path to a specific `claude` executable.
|
||||||
- `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`.
|
- `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`.
|
||||||
|
|
||||||
|
|||||||
+23
-15
@@ -11,6 +11,28 @@ import { join } from 'node:path'
|
|||||||
import packageJson from '../package.json'
|
import packageJson from '../package.json'
|
||||||
import { getCliArgs } from '@/utils/cliArgs'
|
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> {
|
export function parseExtraHeaders(raw: string | undefined, warn: (message: string) => void = console.warn): Record<string, string> {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {}
|
return {}
|
||||||
@@ -18,21 +40,7 @@ export function parseExtraHeaders(raw: string | undefined, warn: (message: strin
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
return normalizeExtraHeaders(parsed, 'HAPI_EXTRA_HEADERS_JSON', warn)
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
warn('[WARN] Failed to parse HAPI_EXTRA_HEADERS_JSON. Ignoring value.')
|
warn('[WARN] Failed to parse HAPI_EXTRA_HEADERS_JSON. Ignoring value.')
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface Settings {
|
|||||||
cliApiToken?: string
|
cliApiToken?: string
|
||||||
// API URL for server connections (priority: env HAPI_API_URL > this > default)
|
// API URL for server connections (priority: env HAPI_API_URL > this > default)
|
||||||
apiUrl?: string
|
apiUrl?: string
|
||||||
|
// Extra headers for CLI -> hub requests (priority: env HAPI_EXTRA_HEADERS_JSON > this)
|
||||||
|
extraHeaders?: unknown
|
||||||
// Legacy field name (for migration, read-only)
|
// Legacy field name (for migration, read-only)
|
||||||
serverUrl?: string
|
serverUrl?: string
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,8 @@ export interface RunnerLocallyPersistedState {
|
|||||||
startedWithApiUrl?: string;
|
startedWithApiUrl?: string;
|
||||||
startedWithMachineId?: string;
|
startedWithMachineId?: string;
|
||||||
startedWithCliApiTokenHash?: 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.
|
* Original process.argv.slice(2) of the runner process at start time, e.g.
|
||||||
* ['runner', 'start-sync', '--workspace-root', '/home/user/code'].
|
* ['runner', 'start-sync', '--workspace-root', '/home/user/code'].
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|||||||
import { isBunCompiled, projectPath } from '@/projectPath';
|
import { isBunCompiled, projectPath } from '@/projectPath';
|
||||||
import { isProcessAlive, isHapiRunnerProcess, killProcess } from '@/utils/process';
|
import { isProcessAlive, isHapiRunnerProcess, killProcess } from '@/utils/process';
|
||||||
import { configuration } from '@/configuration';
|
import { configuration } from '@/configuration';
|
||||||
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity';
|
import { hashRunnerCliApiToken, hashRunnerExtraHeaders, isRunnerStateCompatibleWithIdentity } from './runnerIdentity';
|
||||||
|
|
||||||
export function getInstalledCliMtimeMs(): number | undefined {
|
export function getInstalledCliMtimeMs(): number | undefined {
|
||||||
if (isBunCompiled()) {
|
if (isBunCompiled()) {
|
||||||
@@ -219,7 +219,8 @@ export async function isRunnerRunningCurrentlyInstalledHappyVersion(): Promise<b
|
|||||||
const currentIdentityMatches = isRunnerStateCompatibleWithIdentity(state, {
|
const currentIdentityMatches = isRunnerStateCompatibleWithIdentity(state, {
|
||||||
apiUrl: currentApiUrl,
|
apiUrl: currentApiUrl,
|
||||||
machineId: currentMachineId,
|
machineId: currentMachineId,
|
||||||
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken)
|
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken),
|
||||||
|
extraHeadersHash: hashRunnerExtraHeaders(configuration.extraHeaders)
|
||||||
});
|
});
|
||||||
logger.debug(`[RUNNER CONTROL] Runner identity match: ${currentIdentityMatches}`, {
|
logger.debug(`[RUNNER CONTROL] Runner identity match: ${currentIdentityMatches}`, {
|
||||||
currentApiUrl,
|
currentApiUrl,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { validateWorkspaceDirectory } from './validateWorkspaceDirectory';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||||
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
||||||
import { hashRunnerCliApiToken } from './runnerIdentity';
|
import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity';
|
||||||
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
|
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
|
||||||
|
|
||||||
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
||||||
@@ -744,6 +744,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
|||||||
startedWithApiUrl: configuration.apiUrl,
|
startedWithApiUrl: configuration.apiUrl,
|
||||||
startedWithMachineId: machineId,
|
startedWithMachineId: machineId,
|
||||||
startedWithCliApiTokenHash: hashRunnerCliApiToken(configuration.cliApiToken),
|
startedWithCliApiTokenHash: hashRunnerCliApiToken(configuration.cliApiToken),
|
||||||
|
startedWithExtraHeadersHash: hashRunnerExtraHeaders(configuration.extraHeaders),
|
||||||
startedWithArgv,
|
startedWithArgv,
|
||||||
startedWithVersionHandoffDisabled,
|
startedWithVersionHandoffDisabled,
|
||||||
runnerLogPath: logger.logFilePath
|
runnerLogPath: logger.logFilePath
|
||||||
@@ -1021,6 +1022,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
|||||||
startedWithApiUrl: fileState.startedWithApiUrl,
|
startedWithApiUrl: fileState.startedWithApiUrl,
|
||||||
startedWithMachineId: fileState.startedWithMachineId,
|
startedWithMachineId: fileState.startedWithMachineId,
|
||||||
startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash,
|
startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash,
|
||||||
|
startedWithExtraHeadersHash: fileState.startedWithExtraHeadersHash,
|
||||||
startedWithArgv,
|
startedWithArgv,
|
||||||
startedWithVersionHandoffDisabled,
|
startedWithVersionHandoffDisabled,
|
||||||
lastHeartbeat: new Date().toLocaleString(),
|
lastHeartbeat: new Date().toLocaleString(),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'
|
import {
|
||||||
|
hashRunnerCliApiToken,
|
||||||
|
hashRunnerExtraHeaders,
|
||||||
|
isRunnerStateCompatibleWithIdentity
|
||||||
|
} from './runnerIdentity'
|
||||||
|
|
||||||
describe('runnerIdentity', () => {
|
describe('runnerIdentity', () => {
|
||||||
it('matches when api url, machine id, token hash all same', () => {
|
it('matches when api url, machine id, token hash all same', () => {
|
||||||
@@ -49,6 +53,113 @@ describe('runnerIdentity', () => {
|
|||||||
)).toBe(false)
|
)).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', () => {
|
it('rejects reused runner when current machine id is missing', () => {
|
||||||
expect(isRunnerStateCompatibleWithIdentity(
|
expect(isRunnerStateCompatibleWithIdentity(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type RunnerConnectionIdentity = {
|
|||||||
apiUrl: string
|
apiUrl: string
|
||||||
machineId?: string
|
machineId?: string
|
||||||
cliApiTokenHash?: string
|
cliApiTokenHash?: string
|
||||||
|
extraHeadersHash?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashRunnerCliApiToken(token: string | null | undefined): string | undefined {
|
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')
|
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(
|
export function isRunnerStateCompatibleWithIdentity(
|
||||||
state: Pick<
|
state: Pick<
|
||||||
RunnerLocallyPersistedState,
|
RunnerLocallyPersistedState,
|
||||||
'startedWithApiUrl' | 'startedWithMachineId' | 'startedWithCliApiTokenHash'
|
| 'startedWithApiUrl'
|
||||||
|
| 'startedWithMachineId'
|
||||||
|
| 'startedWithCliApiTokenHash'
|
||||||
|
| 'startedWithExtraHeadersHash'
|
||||||
>,
|
>,
|
||||||
current: RunnerConnectionIdentity
|
current: RunnerConnectionIdentity
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -34,5 +50,9 @@ export function isRunnerStateCompatibleWithIdentity(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.startedWithExtraHeadersHash !== current.extraHeadersHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }[] {
|
function getLogFiles(logDir: string): { file: string, path: string, modified: Date }[] {
|
||||||
if (!existsSync(logDir)) {
|
if (!existsSync(logDir)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -126,8 +134,7 @@ export async function runDoctorCommand(filter?: 'all' | 'runner'): Promise<void>
|
|||||||
try {
|
try {
|
||||||
settings = await readSettings();
|
settings = await readSettings();
|
||||||
console.log(chalk.bold('\n📄 Settings (settings.json):'));
|
console.log(chalk.bold('\n📄 Settings (settings.json):'));
|
||||||
// Hide cliApiToken in output for security
|
const displaySettings = redactSettingsForDisplay({ ...settings });
|
||||||
const displaySettings = { ...settings, cliApiToken: settings.cliApiToken ? '***' : undefined };
|
|
||||||
console.log(chalk.gray(JSON.stringify(displaySettings, null, 2)));
|
console.log(chalk.gray(JSON.stringify(displaySettings, null, 2)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(chalk.bold('\n📄 Settings:'));
|
console.log(chalk.bold('\n📄 Settings:'));
|
||||||
|
|||||||
@@ -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')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ import chalk from 'chalk'
|
|||||||
import { configuration } from '@/configuration'
|
import { configuration } from '@/configuration'
|
||||||
import { readSettings, updateSettings } from '@/persistence'
|
import { readSettings, updateSettings } from '@/persistence'
|
||||||
import { initializeApiUrl } from '@/ui/apiUrlInit'
|
import { initializeApiUrl } from '@/ui/apiUrlInit'
|
||||||
|
import { initializeExtraHeaders } from '@/ui/extraHeadersInit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize CLI API token
|
* Initialize CLI API token
|
||||||
@@ -21,6 +22,7 @@ import { initializeApiUrl } from '@/ui/apiUrlInit'
|
|||||||
export async function initializeToken(): Promise<void> {
|
export async function initializeToken(): Promise<void> {
|
||||||
// Initialize API URL first (env > settings.json > default)
|
// Initialize API URL first (env > settings.json > default)
|
||||||
await initializeApiUrl()
|
await initializeApiUrl()
|
||||||
|
await initializeExtraHeaders()
|
||||||
|
|
||||||
// 1. Environment variable has highest priority (allows temporary override)
|
// 1. Environment variable has highest priority (allows temporary override)
|
||||||
if (configuration.cliApiToken) {
|
if (configuration.cliApiToken) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ On first run, HAPI:
|
|||||||
|----------|---------|---------------|-------------|
|
|----------|---------|---------------|-------------|
|
||||||
| `CLI_API_TOKEN` | Auto-generated | `cliApiToken` | Shared secret for authentication |
|
| `CLI_API_TOKEN` | Auto-generated | `cliApiToken` | Shared secret for authentication |
|
||||||
| `HAPI_API_URL` | `http://localhost:3006` | `apiUrl` | Hub URL for CLI connections |
|
| `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_HOST` | `127.0.0.1` | `listenHost` | Hub HTTP bind address |
|
||||||
| `HAPI_LISTEN_PORT` | `3006` | `listenPort` | Hub HTTP port |
|
| `HAPI_LISTEN_PORT` | `3006` | `listenPort` | Hub HTTP port |
|
||||||
| `HAPI_PUBLIC_URL` | - | `publicUrl` | Public URL for external access |
|
| `HAPI_PUBLIC_URL` | - | `publicUrl` | Public URL for external access |
|
||||||
@@ -198,13 +198,17 @@ On first run, HAPI:
|
|||||||
Configuration priority: **ENV > settings.json > default**
|
Configuration priority: **ENV > settings.json > default**
|
||||||
|
|
||||||
When ENV values are set and not present in settings.json, they are automatically saved.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://hapi.run/docs/schemas/settings.schema.json",
|
"$schema": "https://hapi.run/docs/schemas/settings.schema.json",
|
||||||
"listenHost": "0.0.0.0",
|
"listenHost": "0.0.0.0",
|
||||||
"listenPort": 3006,
|
"listenPort": 3006,
|
||||||
"publicUrl": "https://your-domain.com"
|
"publicUrl": "https://your-domain.com",
|
||||||
|
"extraHeaders": {
|
||||||
|
"Cookie": "CF_Authorization=..."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"default": "http://localhost:3006",
|
"default": "http://localhost:3006",
|
||||||
"description": "Hub URL for CLI connections. ENV: HAPI_API_URL"
|
"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": {
|
"listenHost": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "127.0.0.1",
|
"default": "127.0.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user