diff --git a/apps/bridge-cli/src/index.test.ts b/apps/bridge-cli/src/index.test.ts index 5297ebe..b0ee6c7 100644 --- a/apps/bridge-cli/src/index.test.ts +++ b/apps/bridge-cli/src/index.test.ts @@ -414,6 +414,23 @@ describe('cli pairing output', () => { exitSpy.mockRestore(); }); + it('passes the Hermes API key from ~/.hermes/.env into local bridge runs', async () => { + mkdirSync(join(process.env.HOME as string, '.hermes'), { recursive: true }); + writeFileSync(join(process.env.HOME as string, '.hermes', '.env'), 'API_SERVER_KEY=api-server-key\n', 'utf8'); + process.argv = ['node', 'clawket', 'hermes', 'run']; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined as never)); + + await import('./index.js'); + + await vi.waitFor(() => { + expect(hermesLocalBridgeCtorMock).toHaveBeenCalledWith(expect.objectContaining({ + apiKey: 'api-server-key', + })); + }); + + exitSpy.mockRestore(); + }); + it('runs Hermes local dev as a single command with QR output', async () => { process.argv = ['node', 'clawket', 'hermes', 'dev', '--public-host', '192.168.31.41', '--port', '4321']; execFileSyncMock.mockReturnValue(`40160 ${process.argv[1]} hermes dev --public-host 192.168.31.41 --port 4321\n`); @@ -481,6 +498,7 @@ describe('cli pairing output', () => { it('supports Hermes local pairing through pair local --backend hermes', async () => { mkdirSync(join(process.env.HOME as string, '.hermes', 'hermes-agent'), { recursive: true }); + writeFileSync(join(process.env.HOME as string, '.hermes', '.env'), 'API_SERVER_KEY=api-server-key\n', 'utf8'); process.argv = [ 'node', 'clawket', @@ -511,6 +529,16 @@ describe('cli pairing output', () => { ); }); expect(buildLocalPairingInfoMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + expect.not.arrayContaining(['--api-key', 'api-server-key']), + expect.objectContaining({ + env: expect.objectContaining({ + CLAWKET_HERMES_API_KEY: 'api-server-key', + CLAWKET_HERMES_BRIDGE_TOKEN: 'hermes-token', + }), + }), + ); }); it('reuses the running Hermes bridge config for local pairing instead of transient CLI flags', async () => { diff --git a/apps/bridge-cli/src/index.ts b/apps/bridge-cli/src/index.ts index 057fa39..43637d1 100644 --- a/apps/bridge-cli/src/index.ts +++ b/apps/bridge-cli/src/index.ts @@ -290,6 +290,7 @@ async function main(): Promise { } const HERMES_BRIDGE_CONFIG_PATH = join(homedir(), '.clawket', 'hermes-bridge.json'); +const HERMES_API_ENV_PATH = join(homedir(), '.hermes', '.env'); type HermesBridgeCliConfig = { token: string; @@ -302,6 +303,7 @@ type HermesBridgeRuntimeOptions = { host: string; port: number; apiBaseUrl: string; + apiKey: string | null; token: string; replaceExisting: boolean; restartHermes: boolean; @@ -579,11 +581,13 @@ async function handleHermesCommand(args: string[], jsonOutput: boolean): Promise const host = readFlag(subArgs, '--host') ?? saved?.host ?? '0.0.0.0'; const port = Number(readFlag(subArgs, '--port') ?? saved?.port ?? '4319'); const apiBaseUrl = readFlag(subArgs, '--api-url') ?? saved?.apiBaseUrl ?? 'http://127.0.0.1:8642'; + const apiKey = resolveHermesApiKey(subArgs); const token = readFlag(subArgs, '--token') ?? process.env.CLAWKET_HERMES_BRIDGE_TOKEN ?? saved?.token ?? randomUUID(); const bridge = await startHermesBridgeRuntime({ host, port, apiBaseUrl, + apiKey, token, replaceExisting: hasFlag(subArgs, '--replace') || !hasFlag(subArgs, '--no-replace'), restartHermes: hasFlag(subArgs, '--restart-hermes'), @@ -616,6 +620,7 @@ async function handleHermesCommand(args: string[], jsonOutput: boolean): Promise const host = readFlag(subArgs, '--host') ?? saved?.host ?? '0.0.0.0'; const port = Number(readFlag(subArgs, '--port') ?? saved?.port ?? '4319'); const apiBaseUrl = readFlag(subArgs, '--api-url') ?? saved?.apiBaseUrl ?? 'http://127.0.0.1:8642'; + const apiKey = resolveHermesApiKey(subArgs); const token = readFlag(subArgs, '--token') ?? process.env.CLAWKET_HERMES_BRIDGE_TOKEN ?? saved?.token ?? randomUUID(); const publicHost = readFlag(subArgs, '--public-host') ?? detectLanIp(); if (!publicHost) { @@ -626,6 +631,7 @@ async function handleHermesCommand(args: string[], jsonOutput: boolean): Promise host, port, apiBaseUrl, + apiKey, token, replaceExisting: hasFlag(subArgs, '--replace') || !hasFlag(subArgs, '--no-replace'), restartHermes: hasFlag(subArgs, '--restart-hermes'), @@ -1248,7 +1254,7 @@ function isKnownClawketBridgeCliCommand(command: string): boolean { } async function startHermesBridgeRuntime(options: HermesBridgeRuntimeOptions): Promise { - const { host, port, apiBaseUrl, token, replaceExisting, restartHermes, startHermesIfNeeded } = options; + const { host, port, apiBaseUrl, apiKey, token, replaceExisting, restartHermes, startHermesIfNeeded } = options; const existingBridgePids = listHermesBridgeRuntimePids(); if (existingBridgePids.length > 0) { @@ -1279,6 +1285,7 @@ async function startHermesBridgeRuntime(options: HermesBridgeRuntimeOptions): Pr host, port, apiBaseUrl, + apiKey: apiKey ?? undefined, bridgeToken: token, startHermesIfNeeded, onLog: (line) => { @@ -1456,12 +1463,13 @@ async function ensureHermesRelayBackgroundRuntime(args: string[]): Promise { const startedAt = Date.now(); @@ -1739,6 +1790,7 @@ async function ensureHermesPairingRuntimeReady(args: string[]): Promise<{ const host = readFlag(args, '--host') ?? saved?.host ?? '0.0.0.0'; const port = Number(readFlag(args, '--port') ?? saved?.port ?? '4319'); const apiBaseUrl = readFlag(args, '--api-url') ?? saved?.apiBaseUrl ?? 'http://127.0.0.1:8642'; + const apiKey = resolveHermesApiKey(args); const token = readFlag(args, '--token') ?? process.env.CLAWKET_HERMES_BRIDGE_TOKEN ?? saved?.token ?? randomUUID(); const existingBridgePids = listHermesBridgeRuntimePids(); @@ -1760,6 +1812,7 @@ async function ensureHermesPairingRuntimeReady(args: string[]): Promise<{ host, port, apiBaseUrl, + apiKey, token, restartHermes: hasFlag(args, '--restart-hermes'), }); @@ -1769,13 +1822,14 @@ async function ensureHermesPairingRuntimeReady(args: string[]): Promise<{ port, }); - return { host, port, apiBaseUrl, token }; + return { host, port, apiBaseUrl, apiKey, token }; } async function resolveExistingHermesPairingRuntime(saved: HermesBridgeCliConfig | null): Promise<{ host: string; port: number; apiBaseUrl: string; + apiKey: string | null; token: string; }> { if (!saved?.token) { @@ -1791,6 +1845,7 @@ async function resolveExistingHermesPairingRuntime(saved: HermesBridgeCliConfig host: runningBridge?.host ?? saved.host, port: runningBridge?.port ?? saved.port, apiBaseUrl: health.hermesApiBaseUrl ?? saved.apiBaseUrl, + apiKey: resolveHermesApiKey([]), token: saved.token, }; } @@ -1799,16 +1854,15 @@ function startDetachedHermesBridgeRuntime(input: { host: string; port: number; apiBaseUrl: string; + apiKey: string | null; token: string; restartHermes: boolean; }): void { const logFiles = getHermesProcessLogPaths(); const stdoutFd = openSync(logFiles.bridgeLogPath, 'a'); const stderrFd = openSync(logFiles.bridgeErrorLogPath, 'a'); - // Pass the bridge token via the environment instead of argv so it does - // not appear in `ps`, /proc//cmdline, or shell history. The child - // `clawket hermes run` handler reads `CLAWKET_HERMES_BRIDGE_TOKEN` as a - // fallback when `--token` is not passed explicitly. + // Pass secrets via the environment instead of argv so they do not appear + // in `ps`, /proc//cmdline, or shell history. const childArgs = [ resolveCurrentScriptPath(), 'hermes', @@ -1827,7 +1881,7 @@ function startDetachedHermesBridgeRuntime(input: { const child = spawn(process.execPath, childArgs, { detached: true, stdio: ['ignore', stdoutFd, stderrFd], - env: { ...process.env, CLAWKET_HERMES_BRIDGE_TOKEN: input.token }, + env: buildHermesChildEnv(input.token, input.apiKey), }); closeSync(stdoutFd); closeSync(stderrFd); @@ -1838,17 +1892,15 @@ function startDetachedHermesRelayRuntime(input: { host: string; port: number; apiBaseUrl: string; + apiKey: string | null; token: string; restartHermes: boolean; }): void { const logFiles = getHermesProcessLogPaths(); const stdoutFd = openSync(logFiles.relayLogPath, 'a'); const stderrFd = openSync(logFiles.relayErrorLogPath, 'a'); - // Pass the bridge token via the environment instead of argv so it does - // not appear in `ps`, /proc//cmdline, or shell history. The child - // `clawket hermes relay run` handler (via ensureHermesPairingRuntimeReady) - // reads `CLAWKET_HERMES_BRIDGE_TOKEN` as a fallback when `--token` is not - // passed explicitly. + // Pass secrets via the environment instead of argv so they do not appear + // in `ps`, /proc//cmdline, or shell history. const childArgs = [ resolveCurrentScriptPath(), 'hermes', @@ -1867,7 +1919,7 @@ function startDetachedHermesRelayRuntime(input: { const child = spawn(process.execPath, childArgs, { detached: true, stdio: ['ignore', stdoutFd, stderrFd], - env: { ...process.env, CLAWKET_HERMES_BRIDGE_TOKEN: input.token }, + env: buildHermesChildEnv(input.token, input.apiKey), }); closeSync(stdoutFd); closeSync(stderrFd); @@ -2054,9 +2106,9 @@ function sleep(ms: number): Promise { function printHelp(): void { console.log([ - 'clawket pair [--backend ] [--server ] [--name ] [--public-host <192.168.x.x>] [--port <4319>] [--qr-file ] [--json] [--force]', - 'clawket pair local [--backend ] [--url ] [--public-host <192.168.x.x>] [--port <4319>] [--qr-file ] [--json]', - 'clawket pair --local [--backend ] [--url ] [--public-host <192.168.x.x>] [--port <4319>] [--qr-file ] [--json]', + 'clawket pair [--backend ] [--server ] [--name ] [--public-host <192.168.x.x>] [--port <4319>] [--api-key ] [--qr-file ] [--json] [--force]', + 'clawket pair local [--backend ] [--url ] [--public-host <192.168.x.x>] [--port <4319>] [--api-key ] [--qr-file ] [--json]', + 'clawket pair --local [--backend ] [--url ] [--public-host <192.168.x.x>] [--port <4319>] [--api-key ] [--qr-file ] [--json]', 'clawket refresh-code [--qr-file ] [--json]', 'clawket start', 'clawket install', @@ -2068,11 +2120,11 @@ function printHelp(): void { 'clawket logs [--last <2m>] [--lines <200>] [--errors] [--follow] [--json]', 'clawket doctor [--json]', 'clawket run [--gateway-url ] [--replace]', - 'clawket hermes dev [--public-host <192.168.x.x>] [--host <0.0.0.0>] [--port <4319>] [--api-url ] [--qr-file ] [--restart-hermes] [--json]', - 'clawket hermes run [--host <0.0.0.0>] [--port <4319>] [--api-url ] [--restart-hermes]', - 'clawket hermes pair local [--public-host <192.168.x.x>] [--port <4319>] [--qr-file ] [--json]', + 'clawket hermes dev [--public-host <192.168.x.x>] [--host <0.0.0.0>] [--port <4319>] [--api-url ] [--api-key ] [--qr-file ] [--restart-hermes] [--json]', + 'clawket hermes run [--host <0.0.0.0>] [--port <4319>] [--api-url ] [--api-key ] [--restart-hermes]', + 'clawket hermes pair local [--public-host <192.168.x.x>] [--port <4319>] [--api-key ] [--qr-file ] [--json]', 'clawket hermes pair relay [--server ] [--name ] [--qr-file ] [--json]', - 'clawket hermes relay run [--host <127.0.0.1>] [--port <4319>] [--api-url ] [--restart-hermes] [--json]', + 'clawket hermes relay run [--host <127.0.0.1>] [--port <4319>] [--api-url ] [--api-key ] [--restart-hermes] [--json]', ].join('\n')); } diff --git a/packages/bridge-runtime/src/hermes.test.ts b/packages/bridge-runtime/src/hermes.test.ts index 12a77af..72315a4 100644 --- a/packages/bridge-runtime/src/hermes.test.ts +++ b/packages/bridge-runtime/src/hermes.test.ts @@ -1453,6 +1453,69 @@ describe('HermesLocalBridge history metadata', () => { }); }); + it('keeps local user prompts ordered before newer native assistant replies', async () => { + const hermesStateDbPath = await createHermesStateDbPath(); + const hermesHomePath = await createHermesHomePath(); + execFileSync('python3', [ + '-c', + [ + 'import sqlite3, sys', + 'db_path = sys.argv[1]', + 'conn = sqlite3.connect(db_path)', + 'conn.execute("""CREATE TABLE sessions (', + ' id TEXT PRIMARY KEY, source TEXT NOT NULL, user_id TEXT, model TEXT, model_config TEXT, system_prompt TEXT,', + ' parent_session_id TEXT, started_at REAL NOT NULL, ended_at REAL, end_reason TEXT,', + ' message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0,', + ' input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0,', + ' billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, estimated_cost_usd REAL, actual_cost_usd REAL, cost_status TEXT, cost_source TEXT, pricing_version TEXT, title TEXT', + ')""")', + 'conn.execute("""CREATE TABLE messages (', + ' id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT,', + ' tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, reasoning TEXT, reasoning_details TEXT, codex_reasoning_items TEXT', + ')""")', + 'conn.execute("INSERT INTO sessions (id, source, model, billing_provider, started_at, title) VALUES (?, ?, ?, ?, ?, ?)", ("clawket-hermes:main", "api_server", "gpt-5.3-codex", "openai-codex", 1, "Hermes"))', + 'conn.execute("INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", ("clawket-hermes:main", "assistant", "native answer", 2))', + 'conn.commit()', + 'conn.close()', + ].join('\n'), + hermesStateDbPath, + ]); + + const bridge = new HermesLocalBridge({ + hermesStateDbPath, + hermesHomePath, + sessionStorePath: await createSessionStorePath(), + startHermesIfNeeded: false, + }); + + (bridge as any).sessionStore.appendMessage('main', { + role: 'user', + content: 'local question', + ts: 1_000, + runId: 'run_local', + }); + + await expect((bridge as any).dispatchRequest('chat.history', { + sessionKey: 'main', + limit: 50, + })).resolves.toEqual({ + thinkingLevel: 'medium', + sessionId: 'clawket-hermes:main', + messages: [ + expect.objectContaining({ + role: 'user', + content: 'local question', + }), + expect.objectContaining({ + role: 'assistant', + content: 'native answer', + model: 'gpt-5.3-codex', + provider: 'openai-codex', + }), + ], + }); + }); + it('preserves timestamp and idempotencyKey for user history entries', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ run_id: 'run_1' }), { diff --git a/packages/bridge-runtime/src/hermes.ts b/packages/bridge-runtime/src/hermes.ts index e0f547e..f6f37e6 100644 --- a/packages/bridge-runtime/src/hermes.ts +++ b/packages/bridge-runtime/src/hermes.ts @@ -219,6 +219,20 @@ function isDuplicateHermesHistoryMessage( return false; } +function compareHermesHistoryMessages(left: HermesHistoryMessage, right: HermesHistoryMessage): number { + const timestampDelta = left.timestamp - right.timestamp; + if (timestampDelta !== 0) return timestampDelta; + return getHermesHistoryRoleRank(left.role) - getHermesHistoryRoleRank(right.role); +} + +function getHermesHistoryRoleRank(role: string): number { + if (role === 'system') return 0; + if (role === 'user') return 1; + if (role === 'assistant') return 2; + if (role === 'toolResult') return 3; + return 4; +} + type HermesBridgeStoreState = { version: 1; sessions: HermesBridgeSession[]; @@ -1333,9 +1347,7 @@ export class HermesLocalBridge { }); const localSession = this.sessionStore.findSession(key); - const lastNativeTimestamp = native.messages.at(-1)?.timestamp ?? 0; - const appendedLocalMessages = (localSession?.messages ?? []) - .filter((message) => message.ts > lastNativeTimestamp) + const localMessages = (localSession?.messages ?? []) .filter((message) => !isDuplicateHermesHistoryMessage(message, native.messages)) .map((message) => ({ role: message.role, @@ -1352,7 +1364,7 @@ export class HermesLocalBridge { toolFinishedAt: message.toolFinishedAt, })); - const mergedMessages = [...native.messages, ...appendedLocalMessages]; + const mergedMessages = [...native.messages, ...localMessages].sort(compareHermesHistoryMessages); const trimmedMessages = limit > 0 ? mergedMessages.slice(-limit) : mergedMessages; return { messages: trimmedMessages,