fix: distinguish CLI-probe timeouts from not_found, resolve Hermes Agent keyId server-side (#10710+10711) (#10746)

#10710: locateCommand() in cliRuntime.ts collapsed a genuine probe timeout
(runProcess's timedOut flag) into the same reason:"not_found" as a truly
absent binary, on both the where.exe and `command -v` branches. Give
timeouts a distinct "timeout" reason, keep trying remaining command
candidates in locateCommandCandidate instead of treating a timeout as
terminal, and extend the settings-file fallback (cliInstallFallback.ts) to
also cover the new "timeout" reason, matching the scenario it already
existed for.

#10711: the Hermes Agent dashboard "Apply" flow only ever sends `keyId`
(never a raw `apiKey`), but the hermes-agent-settings POST handler never
resolved it, so generateHermesAgentConfig() always fell through to the
literal placeholder "YOUR_OMNIROUTE_API_KEY_HERE" for
providers.omniroute.api_key, delegation.api_key, and every
auxiliary.*.api_key. Resolve keyId server-side via getApiKeyById(), the
same precedented pattern already used by claude-settings/route.ts and
codex-settings/route.ts.

Bug 2 from #10710 (hermes tool-detector configPath) was already fixed by
commit 0a74bfbdea -- confirmed still intact,
no action needed.

Co-authored-by: Markus Hartung <mail@hartmark.se>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-19 11:08:34 -03:00
committed by GitHub
parent e1c2425ed7
commit 4d92dfe0a2
7 changed files with 367 additions and 9 deletions

View File

@@ -0,0 +1 @@
- fix(cli): distinguish a CLI-probe timeout from a genuinely absent binary in locateCommand, and resolve the Hermes Agent Apply flow's `keyId` server-side instead of writing the `YOUR_OMNIROUTE_API_KEY_HERE` placeholder (#10710, #10711)

View File

@@ -10,6 +10,7 @@ import {
getCurrentHermesAgentRoles,
} from "@/lib/cli-helper/config-generator/hermes-agent";
import { getHermesConfigPath } from "@/lib/cli-helper/config-generator/hermesHome";
import { getApiKeyById } from "@/lib/db/apiKeys";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
const hermesAgentSettingsSchema = z.object({
@@ -99,10 +100,29 @@ export async function POST(request: Request) {
await fs.mkdir(configDir, { recursive: true });
// #10711: HermesAgentToolCard's "Apply" flow only ever sends `keyId` (never
// a raw `apiKey`) — the same precedented pattern as claude-settings/route.ts
// and codex-settings/route.ts. Resolve the real key by ID here so
// generateHermesAgentConfig() does not fall through to its
// "YOUR_OMNIROUTE_API_KEY_HERE" placeholder. Never trust a client-supplied
// key string directly: the /api/keys list endpoint returns masked values,
// so the only safe source of a usable key is resolving by ID from the DB.
let resolvedApiKey = apiKey ?? null;
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
resolvedApiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever apiKey (if any) was already provided.
}
}
const payload = {
baseUrl,
keyId,
apiKey,
apiKey: resolvedApiKey,
selections,
};

View File

@@ -18,11 +18,13 @@ import fsSync from "fs";
* for any CLI tool that declares a `settings` config path (currently
* `claude` and `droid` — see `CLI_TOOLS` in `cliRuntime.ts`).
*
* Only applies when the lookup's own reason is "not_found" — i.e. the binary
* genuinely couldn't be located on PATH/known install paths. Deliberate
* security rejections (unsafe/relative env override paths, symlink escapes,
* suspicious file sizes, etc.) must stay `installed:false` regardless of
* whether a settings file happens to exist.
* Only applies when the lookup's own reason is "not_found" or "timeout" —
* i.e. the binary genuinely couldn't be located on PATH/known install paths,
* or the probe never got a chance to answer (#10710: a probe timeout is one
* more variant of "not currently resolvable", the exact scenario this
* fallback exists for). Deliberate security rejections (unsafe/relative env
* override paths, symlink escapes, suspicious file sizes, etc.) must stay
* `installed:false` regardless of whether a settings file happens to exist.
*/
export interface NotInstalledResult {
installed: false;
@@ -54,7 +56,9 @@ export const withSettingsFallback = (
settingsPath: string | undefined,
notInstalledResult: NotInstalledResult
): NotInstalledResult | SettingsFallbackResult => {
if (notInstalledResult.reason !== "not_found") return notInstalledResult;
if (notInstalledResult.reason !== "not_found" && notInstalledResult.reason !== "timeout") {
return notInstalledResult;
}
if (!settingsPath || !fsSync.existsSync(settingsPath)) return notInstalledResult;
return {

View File

@@ -856,6 +856,16 @@ export const locateCommand = async (command: string, env: Record<string, string
const preferred = lines.find((l: string) => winExt.test(l)) || lines[0];
return { installed: true, commandPath: normalizeMsys2Path(preferred), reason: null };
}
// #10710: a probe timeout is NOT the same fact as a genuinely absent binary
// -- runProcess sets `timedOut` when its own 3s timer SIGKILLs the child
// before it answered. Collapsing that into "not_found" makes an installed
// CLI starved under concurrent fan-out (see all-statuses route) look
// identical to one that was never installed. Surface a distinct reason so
// callers can decide (retry, remember-and-continue, etc.) instead of
// silently reporting a false negative.
if (located.timedOut) {
return { installed: false, commandPath: null, reason: "timeout" };
}
return { installed: false, commandPath: null, reason: "not_found" };
}
@@ -866,6 +876,11 @@ export const locateCommand = async (command: string, env: Record<string, string
if (located.ok && located.stdout) {
return { installed: true, commandPath: command, reason: null };
}
// #10710: see the matching Windows branch above -- a timeout must not be
// reported as "not_found".
if (located.timedOut) {
return { installed: false, commandPath: null, reason: "timeout" };
}
return { installed: false, commandPath: null, reason: "not_found" };
};
@@ -945,7 +960,7 @@ export const checkKnownPath = async (commandPath: string) => {
type KnownPathResult = Awaited<ReturnType<typeof checkKnownPath>>;
const locateCommandCandidate = async (
export const locateCommandCandidate = async (
commands: string[],
env: Record<string, string | undefined>,
toolId?: string
@@ -975,13 +990,31 @@ const locateCommandCandidate = async (
// Always try PATH — a stray/broken known-path guess must never hide a genuinely
// PATH-resolvable binary (#7774). User can also set CLI_EXTRA_PATHS if needed.
//
// #10710: "timeout" is deliberately NOT terminal like other failure reasons
// (unsafe_path, symlink_escape, ...). A timeout only proves the probe was
// too slow, not that the binary is absent, so remaining command aliases are
// still worth trying (the next one may resolve quickly). Remember the first
// timeout as a fallback so a genuine "not_found" for every alias doesn't
// silently swallow the fact that one probe never actually completed.
let bestTimeoutFailure: Awaited<ReturnType<typeof locateCommand>> | null = null;
for (const command of commands) {
const located = await locateCommand(command, env);
if (located.installed || located.reason !== "not_found") {
if (located.installed) {
return { command, ...located };
}
if (located.reason === "timeout") {
if (!bestTimeoutFailure) bestTimeoutFailure = located;
continue;
}
if (located.reason !== "not_found") {
return { command, ...located };
}
}
if (bestTimeoutFailure) {
return { command: commands[0], ...bestTimeoutFailure };
}
if (bestKnownPathFailure) {
return { command: commands[0], ...bestKnownPathFailure };
}

View File

@@ -0,0 +1,63 @@
/**
* Regression test for #10711.
*
* The Hermes Agent dashboard "Apply" flow (HermesAgentToolCard.tsx) only
* ever sends `keyId` (never a raw `apiKey`). generateHermesAgentConfig()
* used to never resolve `keyId` at all, so `providers.omniroute.api_key`,
* `delegation.api_key`, and every `auxiliary.*.api_key` were written with
* the hardcoded placeholder "YOUR_OMNIROUTE_API_KEY_HERE", yielding 401s
* against OmniRoute for every real user.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import * as yaml from "js-yaml";
import { generateHermesAgentConfig } from "../../../src/lib/cli-helper/config-generator/hermes-agent.ts";
interface HermesAgentParsedConfig {
providers: { omniroute: { api_key: string } };
delegation: { api_key: string };
auxiliary: Record<string, { api_key: string }>;
}
test("#10711: generateHermesAgentConfig writes placeholder api_key when only keyId is supplied (no apiKey)", async () => {
const result = await generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
keyId: "some-stored-key-id", // what the real dashboard flow actually sends
apiKey: null, // never resolved server-side — this is the bug
selections: [
{ role: "default", model: "gpt-4o" },
{ role: "delegation", model: "gpt-4o" },
{ role: "vision", model: "gpt-4o-vision" },
],
});
assert.equal(result.error, undefined);
const parsed = yaml.load(result.yaml) as HermesAgentParsedConfig;
// generateHermesAgentConfig() itself never resolves keyId (that now happens
// in the route handler before calling it) -- confirms the fallthrough this
// bug depends on still exists at this layer, and that an explicit apiKey
// (as the resolved route now passes) overrides the placeholder.
assert.equal(parsed.providers.omniroute.api_key, "YOUR_OMNIROUTE_API_KEY_HERE");
});
test("#10711: an explicit apiKey (as resolved server-side from keyId) is written everywhere, never the placeholder", async () => {
const result = await generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
keyId: "some-stored-key-id",
apiKey: "sk-resolved-real-key-value",
selections: [
{ role: "default", model: "gpt-4o" },
{ role: "delegation", model: "gpt-4o" },
{ role: "vision", model: "gpt-4o-vision" },
],
});
assert.equal(result.error, undefined);
const parsed = yaml.load(result.yaml) as HermesAgentParsedConfig;
assert.equal(parsed.providers.omniroute.api_key, "sk-resolved-real-key-value");
assert.equal(parsed.delegation.api_key, "sk-resolved-real-key-value");
assert.equal(parsed.auxiliary.vision.api_key, "sk-resolved-real-key-value");
});

View File

@@ -0,0 +1,121 @@
/**
* Regression test for #10710.
*
* locateCommand() (src/shared/services/cliRuntime.ts) used to collapse a
* genuine probe timeout (runProcess's internal 3s timer SIGKILLing the
* child) into the exact same reason:"not_found" as a truly-absent binary.
* That made an installed CLI whose where.exe/`command -v` probe was starved
* (e.g. under the all-statuses route's wide, undeduped ~30-tool fan-out)
* indistinguishable from one that was never installed at all.
*/
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { createRequire, syncBuiltinESMExports } from "node:module";
import { pathToFileURL } from "node:url";
import { EventEmitter } from "node:events";
const require = createRequire(import.meta.url);
const childProcess = require("node:child_process");
const modulePath = path.join(process.cwd(), "src/shared/services/cliRuntime.ts");
const originalSpawn = childProcess.spawn;
async function importFresh(label: string) {
return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}-${Math.random()}`);
}
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: (signal?: string) => boolean;
};
function installTimeoutSpawn() {
childProcess.spawn = () => {
const child = new EventEmitter() as FakeChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
// Model a real SIGKILL: the OS delivers 'close' shortly after the kill,
// not synchronously -- but not before the internal 3s timeout fires.
child.kill = () => {
setImmediate(() => child.emit("close", null));
return true;
};
return child;
};
syncBuiltinESMExports();
}
test.afterEach(() => {
childProcess.spawn = originalSpawn;
syncBuiltinESMExports();
});
test("#10710: locateCommand surfaces a distinct 'timeout' reason, not 'not_found'", async () => {
installTimeoutSpawn();
const cliRuntime = await importFresh("locate-timeout");
const start = Date.now();
const result = await cliRuntime.locateCommand("codex", { PATH: process.env.PATH });
const elapsedMs = Date.now() - start;
assert.ok(elapsedMs >= 2900, `expected the internal 3s timeout to fire, took ${elapsedMs}ms`);
assert.equal(result.installed, false);
assert.equal(
result.reason,
"timeout",
"a probe timeout must not be relabeled as not_found -- indistinguishable from a genuinely absent binary"
);
});
test("#10710: a timed-out first candidate does not hide a second candidate that resolves", async () => {
let call = 0;
childProcess.spawn = (_command: string) => {
call += 1;
const child = new EventEmitter() as FakeChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
if (call === 1) {
// First candidate (e.g. "codex-preview") times out.
child.kill = () => {
setImmediate(() => child.emit("close", null));
return true;
};
} else {
// Second candidate (e.g. "codex") resolves quickly and successfully.
child.kill = () => true;
setImmediate(() => {
child.stdout.emit("data", Buffer.from("/usr/local/bin/codex\n"));
child.emit("close", 0);
});
}
return child;
};
syncBuiltinESMExports();
const cliRuntime = await importFresh("locate-candidate-fallthrough");
const result = await cliRuntime.locateCommandCandidate(
["codex-preview", "codex"],
{ PATH: process.env.PATH },
undefined
);
assert.equal(result.installed, true, "the second, quickly-resolving candidate must still be found");
assert.equal(result.command, "codex");
});
test("#10710: when every candidate times out, the caller sees 'timeout' rather than 'not_found'", async () => {
installTimeoutSpawn();
const cliRuntime = await importFresh("locate-candidate-all-timeout");
const result = await cliRuntime.locateCommandCandidate(
["codex-preview", "codex"],
{ PATH: process.env.PATH },
undefined
);
assert.equal(result.installed, false);
assert.equal(result.reason, "timeout");
});

View File

@@ -0,0 +1,116 @@
/**
* Regression test for #10711.
*
* The Hermes Agent dashboard "Apply" flow (HermesAgentToolCard.tsx) only ever
* sends `{ keyId, selections }` — never a raw `apiKey` — because resolving a
* real key from a stored keyId is expected to happen server-side, mirroring
* claude-settings/route.ts and codex-settings/route.ts. The POST handler for
* hermes-agent-settings never resolved `keyId` before this fix, so it always
* fell through to the literal placeholder "YOUR_OMNIROUTE_API_KEY_HERE" in
* providers.omniroute.api_key, delegation.api_key, and every auxiliary.*.api_key.
*
* This test drives the real POST handler end-to-end (real DB-backed API key,
* real JWT auth cookie, preview mode so nothing is written to disk) and
* asserts the generated YAML carries the real resolved key.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
import * as yaml from "js-yaml";
interface HermesAgentParsedConfig {
providers: { omniroute: { api_key: string } };
delegation: { api_key: string };
auxiliary: Record<string, { api_key: string }>;
}
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-hermes-agent-10711-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "hermes-agent-10711-api-secret";
process.env.JWT_SECRET = "hermes-agent-10711-jwt-secret";
process.env.CLI_ALLOW_CONFIG_WRITES = "true";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const route = await import("../../src/app/api/cli-tools/hermes-agent-settings/route.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
async function authCookie(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const jwt = await new SignJWT({ sub: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${jwt}`;
}
test("#10711: POST hermes-agent-settings resolves keyId server-side instead of writing the placeholder", async () => {
const created = await apiKeysDb.createApiKey("hermes-agent-10711-key", "hermes-agent-10711-machine");
const realKey = created.key;
assert.ok(realKey && realKey.length > 0, "createApiKey must return the real plaintext key");
const response = await route.POST(
new Request("http://localhost/api/cli-tools/hermes-agent-settings", {
method: "POST",
headers: {
cookie: await authCookie(),
"Content-Type": "application/json",
},
body: JSON.stringify({
baseUrl: "http://localhost:20128",
keyId: created.id,
selections: [
{ role: "default", model: "gpt-4o" },
{ role: "delegation", model: "gpt-4o" },
{ role: "vision", model: "gpt-4o-vision" },
],
preview: true,
}),
})
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
const parsed = yaml.load(body.yaml) as HermesAgentParsedConfig;
assert.notEqual(
parsed.providers.omniroute.api_key,
"YOUR_OMNIROUTE_API_KEY_HERE",
"providers.omniroute.api_key must not be the unresolved placeholder"
);
assert.equal(parsed.providers.omniroute.api_key, realKey);
assert.equal(parsed.delegation.api_key, realKey);
assert.equal(parsed.auxiliary.vision.api_key, realKey);
});
test("#10711: POST hermes-agent-settings falls back gracefully when keyId does not resolve", async () => {
const response = await route.POST(
new Request("http://localhost/api/cli-tools/hermes-agent-settings", {
method: "POST",
headers: {
cookie: await authCookie(),
"Content-Type": "application/json",
},
body: JSON.stringify({
baseUrl: "http://localhost:20128",
keyId: "does-not-exist-in-db",
selections: [{ role: "default", model: "gpt-4o" }],
preview: true,
}),
})
);
// Must not crash the Apply flow — still succeeds, just without a resolved key.
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
});