diff --git a/cli/README.md b/cli/README.md index cd6c2ddd0..c111e0af8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -80,6 +80,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_CLAUDE_PATH` - Path to a specific `claude` executable. - `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`. diff --git a/cli/src/api/api.extraHeaders.test.ts b/cli/src/api/api.extraHeaders.test.ts new file mode 100644 index 000000000..a21587dc8 --- /dev/null +++ b/cli/src/api/api.extraHeaders.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configuration } from '@/configuration' + +const axiosPostMock = vi.hoisted(() => vi.fn()) +const ioMock = vi.hoisted(() => vi.fn()) + +vi.mock('axios', () => ({ + default: { + post: axiosPostMock + } +})) + +vi.mock('@/api/auth', () => ({ + getAuthToken: () => 'cli-token' +})) + +vi.mock('socket.io-client', () => ({ + io: ioMock +})) + +vi.mock('@/api/rpc/RpcHandlerManager', () => ({ + RpcHandlerManager: class { + onSocketConnect(): void { } + onSocketDisconnect(): void { } + registerHandler(): void { } + handleRequest(): Promise { + return Promise.resolve('{}') + } + } +})) + +vi.mock('../modules/common/registerCommonHandlers', () => ({ + registerCommonHandlers: () => { } +})) + +vi.mock('@/terminal/TerminalManager', () => ({ + TerminalManager: class { + closeAll(): void { } + } +})) + +import { ApiClient } from './api' +import { ApiSessionClient } from './apiSession' + +describe('API extra headers integration', () => { + const now = 1_710_000_000_000 + + beforeEach(() => { + configuration._setApiUrl('https://hapi.example.com') + configuration._setExtraHeaders({}) + axiosPostMock.mockReset() + ioMock.mockReset() + }) + + it('adds extra headers to REST requests', async () => { + configuration._setExtraHeaders({ + Cookie: 'CF_Authorization=token' + }) + + axiosPostMock.mockResolvedValue({ + data: { + session: { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: now, + updatedAt: now, + active: true, + activeAt: now, + metadata: { + path: '/tmp/project', + host: 'test-host' + }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: now, + todos: [], + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: undefined + } + } + }) + + const client = await ApiClient.create() + await client.getOrCreateSession({ + tag: 'test', + metadata: { + path: '/tmp/project', + host: 'test-host' + }, + state: null + }) + + expect(axiosPostMock).toHaveBeenCalledOnce() + expect(axiosPostMock.mock.calls[0]?.[2]).toMatchObject({ + headers: { + Cookie: 'CF_Authorization=token', + Authorization: 'Bearer cli-token', + 'Content-Type': 'application/json' + } + }) + }) + + it('adds extra headers to socket transport options', () => { + configuration._setExtraHeaders({ + Cookie: 'CF_Authorization=token' + }) + + const fakeSocket = { + on: vi.fn(), + connect: vi.fn(), + emit: vi.fn(), + volatile: { emit: vi.fn() } + } + ioMock.mockReturnValue(fakeSocket) + + new ApiSessionClient('cli-token', { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: now, + updatedAt: now, + active: true, + activeAt: now, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: now, + todos: [], + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: undefined + }) + + expect(ioMock).toHaveBeenCalledOnce() + expect(ioMock.mock.calls[0]?.[1]).toMatchObject({ + extraHeaders: { + Cookie: 'CF_Authorization=token' + } + }) + }) +}) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 614b3d6bb..5e05e77e1 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -6,6 +6,7 @@ import { getAuthToken } from '@/api/auth' import { apiValidationError } from '@/utils/errorUtils' import { ApiMachineClient } from './apiMachine' import { ApiSessionClient } from './apiSession' +import { buildHubRequestHeaders } from './hubExtraHeaders' export class ApiClient { static async create(): Promise { @@ -33,10 +34,10 @@ export class ApiClient { effort: opts.effort }, { - headers: { + headers: buildHubRequestHeaders({ Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' - }, + }), timeout: 60_000 } ) @@ -96,10 +97,10 @@ export class ApiClient { runnerState: opts.runnerState ?? null }, { - headers: { + headers: buildHubRequestHeaders({ Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' - }, + }), timeout: 60_000 } ) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 42a25f835..df3fe56a6 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -15,6 +15,7 @@ import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' +import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' interface ServerToRunnerEvents { update: (data: Update) => void @@ -231,7 +232,8 @@ export class ApiMachineClient { path: '/socket.io/', reconnection: true, reconnectionDelay: 1000, - reconnectionDelayMax: 5000 + reconnectionDelayMax: 5000, + ...buildSocketIoExtraHeaderOptions() }) this.socket.on('connect', () => { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 795946ae7..174a2cdeb 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -34,6 +34,7 @@ import { registerCommonHandlers } from '../modules/common/registerCommonHandlers import { cleanupUploadDir } from '../modules/common/handlers/uploads' import { TerminalManager } from '@/terminal/TerminalManager' import { applyVersionedAck } from './versionedUpdate' +import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' /** * XML tags that Claude Code injects as `type:'user'` messages. @@ -118,7 +119,8 @@ export class ApiSessionClient extends EventEmitter { reconnectionDelay: 1000, reconnectionDelayMax: 5000, transports: ['websocket'], - autoConnect: false + autoConnect: false, + ...buildSocketIoExtraHeaderOptions() }) this.terminalManager = new TerminalManager({ @@ -308,10 +310,10 @@ export class ApiSessionClient extends EventEmitter { `${configuration.apiUrl}/cli/sessions/${encodeURIComponent(this.sessionId)}/messages`, { params: { afterSeq: cursor, limit }, - headers: { + headers: buildHubRequestHeaders({ Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' - }, + }), timeout: 15_000 } ) diff --git a/cli/src/api/hubExtraHeaders.test.ts b/cli/src/api/hubExtraHeaders.test.ts new file mode 100644 index 000000000..60acd484c --- /dev/null +++ b/cli/src/api/hubExtraHeaders.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configuration, parseExtraHeaders } from '@/configuration' +import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' + +describe('parseExtraHeaders', () => { + it('parses a JSON object with string values', () => { + expect(parseExtraHeaders('{"Cookie":"a=b","X-Test":"1"}')).toEqual({ + Cookie: 'a=b', + 'X-Test': '1' + }) + }) + + it('drops non-string values', () => { + const warn = vi.fn() + expect(parseExtraHeaders('{"Cookie":"a=b","X-Num":1,"X-Bool":true}', warn)).toEqual({ + Cookie: 'a=b' + }) + expect(warn).toHaveBeenCalledOnce() + }) + + it('returns empty object and warns for invalid json', () => { + const warn = vi.fn() + expect(parseExtraHeaders('{not-json', warn)).toEqual({}) + expect(warn).toHaveBeenCalledOnce() + }) + + it('returns empty object and warns for non-object json', () => { + const warn = vi.fn() + expect(parseExtraHeaders('["a"]', warn)).toEqual({}) + expect(warn).toHaveBeenCalledOnce() + }) +}) + +describe('hub extra headers helpers', () => { + beforeEach(() => { + configuration._setExtraHeaders({}) + }) + + it('merges custom headers into REST requests without overriding built-in auth headers', () => { + configuration._setExtraHeaders({ + Cookie: 'CF_Authorization=token', + Authorization: 'should-not-win' + }) + + expect(buildHubRequestHeaders({ + Authorization: 'Bearer cli-token', + 'Content-Type': 'application/json' + })).toEqual({ + Cookie: 'CF_Authorization=token', + Authorization: 'Bearer cli-token', + 'Content-Type': 'application/json' + }) + }) + + it('builds socket transport options when extra headers are configured', () => { + configuration._setExtraHeaders({ + Cookie: 'CF_Authorization=token', + 'X-Test': '1' + }) + + expect(buildSocketIoExtraHeaderOptions()).toEqual({ + extraHeaders: { + Cookie: 'CF_Authorization=token', + 'X-Test': '1' + } + }) + }) + + it('returns empty socket options when no extra headers are configured', () => { + expect(buildSocketIoExtraHeaderOptions()).toEqual({}) + }) +}) diff --git a/cli/src/api/hubExtraHeaders.ts b/cli/src/api/hubExtraHeaders.ts new file mode 100644 index 000000000..bdae37ce2 --- /dev/null +++ b/cli/src/api/hubExtraHeaders.ts @@ -0,0 +1,20 @@ +import { configuration } from '@/configuration' + +export function buildHubRequestHeaders(baseHeaders: Record): Record { + return { + ...configuration.extraHeaders, + ...baseHeaders + } +} + +export function buildSocketIoExtraHeaderOptions(): { + extraHeaders?: Record +} { + if (Object.keys(configuration.extraHeaders).length === 0) { + return {} + } + + return { + extraHeaders: { ...configuration.extraHeaders } + } +} diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index b37d3be10..e2a90a31a 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -100,9 +100,9 @@ export const CreateSessionResponseSchema = z.object({ thinking: z.boolean(), thinkingAt: z.number(), todos: TodosSchema.optional(), - model: z.string().nullable(), - modelReasoningEffort: z.string().nullable(), - effort: z.string().nullable(), + model: z.string().nullable().optional().default(null), + modelReasoningEffort: z.string().nullable().optional().default(null), + effort: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) diff --git a/cli/src/configuration.ts b/cli/src/configuration.ts index ac65d6ac9..db59cd20f 100644 --- a/cli/src/configuration.ts +++ b/cli/src/configuration.ts @@ -11,9 +11,38 @@ import { join } from 'node:path' import packageJson from '../package.json' import { getCliArgs } from '@/utils/cliArgs' +export function parseExtraHeaders(raw: string | undefined, warn: (message: string) => void = console.warn): Record { + if (!raw) { + return {} + } + + 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 + } catch { + warn('[WARN] Failed to parse HAPI_EXTRA_HEADERS_JSON. Ignoring value.') + return {} + } +} + class Configuration { private _apiUrl: string private _cliApiToken: string + private _extraHeaders: Record public readonly isRunnerProcess: boolean // Directories and paths (from persistence) @@ -31,6 +60,7 @@ class Configuration { // Server configuration this._apiUrl = process.env.HAPI_API_URL || 'http://localhost:3006' this._cliApiToken = process.env.CLI_API_TOKEN || '' + this._extraHeaders = parseExtraHeaders(process.env.HAPI_EXTRA_HEADERS_JSON) // Check if we're running as runner based on process args const args = getCliArgs() @@ -79,6 +109,14 @@ class Configuration { _setCliApiToken(token: string): void { this._cliApiToken = token } + + get extraHeaders(): Record { + return this._extraHeaders + } + + _setExtraHeaders(headers: Record): void { + this._extraHeaders = { ...headers } + } } export const configuration: Configuration = new Configuration() diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 4fe2b6b50..4fa56ba96 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -177,6 +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_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 | @@ -217,6 +218,7 @@ If the hub is not on localhost, set these before running `hapi`: ```bash export HAPI_API_URL="http://your-hub:3006" export CLI_API_TOKEN="your-token-here" +export HAPI_EXTRA_HEADERS_JSON='{"Cookie":"CF_Authorization=..."}' ``` Or use interactive login: diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index de2125738..c802efe51 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -175,9 +175,9 @@ export const SessionSchema = z.object({ backgroundTaskCount: z.number().optional(), todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), - model: z.string().nullable(), - modelReasoningEffort: z.string().nullable(), - effort: z.string().nullable(), + model: z.string().nullable().optional().default(null), + modelReasoningEffort: z.string().nullable().optional().default(null), + effort: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() })