From dbd4a6141b76eb004150469156f267a90cedc538 Mon Sep 17 00:00:00 2001 From: MrTheSoulz <5899335+MrTheSoulz@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:05:57 +0100 Subject: [PATCH 1/2] [verified] fix: recognize service-managed local gateways Use the running Linux gateway state and process fingerprint for default-profile status. Probe its API listener, preserve named-profile port configuration, and refuse unsupported controls or API-key rotation on externally managed gateways. --- lat.md/connections.md | 20 +++ src/main/connection-status.test.ts | 25 ++++ src/main/connection-status.ts | 12 +- src/main/gateway-liveness.ts | 53 +++++++ src/main/hermes.test.ts | 176 +++++++++++++++++++++++ src/main/hermes.ts | 56 ++++++-- src/main/ipc/register.ts | 12 +- src/main/profiles.gateway-status.test.ts | 59 ++++++++ src/main/profiles.ts | 20 ++- 9 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 src/main/gateway-liveness.ts create mode 100644 src/main/profiles.gateway-status.test.ts diff --git a/lat.md/connections.md b/lat.md/connections.md index 5f89125d8..5ce5ea745 100644 --- a/lat.md/connections.md +++ b/lat.md/connections.md @@ -97,6 +97,26 @@ Focused checks cover independent status classification and the credential bounda Local, Remote, and SSH records report separate health and authentication outcomes, retain connection-specific capability evidence, and never return stored API keys. +### Local multiplex gateway status + +A running default-home gateway reports Local online in Settings without `gateway.pid` only for the default profile, after validating its process fingerprint. Named profiles without a dedicated API listener stay offline. + +### Local API readiness + +Settings checks API reachability for the default Local gateway after confirming its process is live. Named profiles keep PID-based status so a read-only status probe never invokes their port allocator or rewrites config. + +### Externally managed gateway controls + +Hermes One never claims to stop a multiplex gateway owned by a service or other process; its Stop action reports that the gateway must be managed by its owner. + +### Externally managed API keys + +An API key cannot be rotated in Hermes One while another process owns the live default gateway, because that process would keep using its old key until its own supervisor restarts it. + +### Multiplex status in profile lists + +The profile list backing the status bar and agent screens recognizes the default home's live multiplexer for its default profile only; named profiles without a dedicated API listener remain offline. + ### Connection-explicit dashboard routing Direct-Remote dashboard status and WebSocket lookup use the chat's saved connection ID instead of the currently selected record, while inactive SSH records cannot retarget the singleton tunnel. diff --git a/src/main/connection-status.test.ts b/src/main/connection-status.test.ts index 90c73a88f..a03a5a81d 100644 --- a/src/main/connection-status.test.ts +++ b/src/main/connection-status.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ getCachedAgentCapabilityEvidence: vi.fn(), getHermesVersion: vi.fn(), isGatewayRunning: vi.fn(), + isGatewayHealthy: vi.fn(), probeRemoteAuthMode: vi.fn(), remoteOAuthSessionState: vi.fn(), sshGatewayStatus: vi.fn(), @@ -33,6 +34,7 @@ vi.mock("./hermes", () => ({ getAgentCapabilityEvidence: mocks.getAgentCapabilityEvidence, getCachedAgentCapabilityEvidence: mocks.getCachedAgentCapabilityEvidence, isGatewayRunning: mocks.isGatewayRunning, + isGatewayHealthy: mocks.isGatewayHealthy, testRemoteConnection: mocks.testRemoteConnection, })); vi.mock("./installer", () => ({ getHermesVersion: mocks.getHermesVersion })); @@ -73,6 +75,7 @@ describe("connection status snapshots", () => { beforeEach(() => { vi.clearAllMocks(); mocks.isGatewayRunning.mockReturnValue(true); + mocks.isGatewayHealthy.mockResolvedValue(true); mocks.getHermesVersion.mockResolvedValue("Hermes Agent v1.0.0"); mocks.probeRemoteAuthMode.mockResolvedValue({ authMode: "token", @@ -123,4 +126,26 @@ describe("connection status snapshots", () => { "default", ); }); + + // @lat: [[connections#Test specifications#Local API readiness]] + it("reports Local offline when its own API listener is unavailable", async () => { + mocks.isGatewayHealthy.mockResolvedValue(false); + const statuses = await getConnectionStatuses("default"); + expect( + statuses.find((status) => status.connectionId === "local")?.health, + ).toBe("offline"); + expect(mocks.isGatewayHealthy).toHaveBeenCalledWith( + "default", + expect.objectContaining({ mode: "local" }), + ); + }); + + it("does not allocate or rewrite a named profile's API port while probing status", async () => { + mocks.isGatewayHealthy.mockResolvedValue(false); + const statuses = await getConnectionStatuses("scout"); + expect( + statuses.find((status) => status.connectionId === "local")?.health, + ).toBe("online"); + expect(mocks.isGatewayHealthy).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/connection-status.ts b/src/main/connection-status.ts index 3ddfad549..cbf139b62 100644 --- a/src/main/connection-status.ts +++ b/src/main/connection-status.ts @@ -8,10 +8,12 @@ import { import { getAgentCapabilityEvidence, getCachedAgentCapabilityEvidence, + isGatewayHealthy, isGatewayRunning, testRemoteConnection, } from "./hermes"; import { getHermesVersion } from "./installer"; +import { getActiveProfileNameSync } from "./utils"; import { probeRemoteAuthMode, remoteOAuthSessionState } from "./remote-oauth"; import { sshGatewayStatus, sshGetHermesVersion } from "./ssh-remote"; @@ -32,7 +34,15 @@ async function probeConnection( const { config } = connection; if (config.mode === "local") { - const health = isGatewayRunning(profile) ? "online" : "offline"; + // getProfilePort() may persist a new port for named profiles. Status + // reads must not reconfigure an already-running named gateway. + const isDefaultProfile = + (profile ?? getActiveProfileNameSync()) === "default"; + const health = + isGatewayRunning(profile) && + (!isDefaultProfile || (await isGatewayHealthy(profile, config))) + ? "online" + : "offline"; return { health, latencyMs: elapsed(startedAt), diff --git a/src/main/gateway-liveness.ts b/src/main/gateway-liveness.ts new file mode 100644 index 000000000..ab07cd4cc --- /dev/null +++ b/src/main/gateway-liveness.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +/** Match the default home's managed gateway, not a reused PID or a named profile's missing API port. */ +export function isMultiplexGatewayRunning( + home: string, + profile: string, + isGatewayPidAlive: (pid: number) => boolean, +): boolean { + // Named profiles in a multiplexer have no dedicated Local API listener yet. + if (profile !== "default" || process.platform !== "linux") return false; + try { + const state = JSON.parse( + readFileSync(join(home, "gateway_state.json"), "utf-8"), + ) as { + kind?: unknown; + hermes_home?: unknown; + gateway_state?: unknown; + pid?: unknown; + start_time?: unknown; + served_profiles?: unknown; + }; + if ( + state.kind !== "hermes-gateway" || + state.hermes_home !== home || + state.gateway_state !== "running" || + typeof state.pid !== "number" || + !Number.isSafeInteger(state.pid) || + state.pid <= 0 || + typeof state.start_time !== "number" || + !Number.isSafeInteger(state.start_time) || + !Array.isArray(state.served_profiles) || + !state.served_profiles.includes("default") + ) { + return false; + } + // Hermes Agent records Linux /proc field 22 (ticks since boot). Match + // that fingerprint so a dead gateway's PID reused by another process + // cannot make a stale state record appear online. + const stat = readFileSync(`/proc/${state.pid}/stat`, "utf-8"); + const closingParen = stat.lastIndexOf(")"); + if (closingParen < 0) return false; + const startTime = Number( + stat + .slice(closingParen + 2) + .trim() + .split(/\s+/)[19], + ); + return startTime === state.start_time && isGatewayPidAlive(state.pid); + } catch { + return false; + } +} diff --git a/src/main/hermes.test.ts b/src/main/hermes.test.ts index 2092735e1..2c3aba910 100644 --- a/src/main/hermes.test.ts +++ b/src/main/hermes.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; // hermes.ts pulls in the full main-process import graph; mock the modules with // import-time side effects (installer → electron) and the two seams under @@ -65,11 +68,16 @@ import { } from "./config"; import type { ConnectionConfig } from "./config"; import { providerListSafe } from "./secrets"; +import { pidIsAliveAs, profileHome } from "./utils"; import { + assertLocalGatewayKeyMutationAllowed, clearAgentCapabilityEvidence, getAgentCapabilityEvidence, getCachedAgentCapabilityEvidence, getRemoteAuthHeader, + isExternallyManagedMultiplexGateway, + isGatewayRunning, + stopGateway, bindPendingApproval, clearAllPendingApprovals, registerPendingApproval, @@ -92,6 +100,174 @@ const mockedReadEnv = vi.mocked(readEnv); const mockedProviderListSafe = vi.mocked(providerListSafe); const mockedSpawn = vi.mocked(spawn); +describe("local gateway status", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "hermes-one-gateway-status-")); + vi.mocked(profileHome).mockImplementation((profile) => + profile ? join(home, "profiles", String(profile)) : home, + ); + vi.mocked(pidIsAliveAs).mockReturnValue(true); + }); + + afterEach(() => { + vi.mocked(profileHome).mockReset(); + vi.mocked(pidIsAliveAs).mockReset(); + rmSync(home, { recursive: true, force: true }); + }); + + // @lat: [[connections#Test specifications#Local multiplex gateway status]] + it.skipIf(process.platform !== "linux")( + "recognizes a live default-home multiplex gateway without gateway.pid", + () => { + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + gateway_state: "running", + kind: "hermes-gateway", + hermes_home: home, + pid: process.pid, + start_time: startTime, + served_profiles: ["default", "scout"], + }), + ); + + expect(isGatewayRunning("default")).toBe(true); + expect(isGatewayRunning("scout")).toBe(false); + expect(pidIsAliveAs).toHaveBeenCalledWith(process.pid, [ + "python", + "pythonw", + ]); + }, + ); + + it.skipIf(process.platform !== "linux")( + "rejects a reused PID with a different process start fingerprint", + () => { + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + gateway_state: "running", + kind: "hermes-gateway", + hermes_home: home, + pid: process.pid, + start_time: startTime + 1, + served_profiles: ["default"], + }), + ); + expect(isGatewayRunning("default")).toBe(false); + }, + ); + + // @lat: [[connections#Test specifications#Externally managed gateway controls]] + it.skipIf(process.platform !== "linux")( + "refuses to stop a service-owned multiplex gateway", + () => { + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + kind: "hermes-gateway", + hermes_home: home, + gateway_state: "running", + pid: process.pid, + start_time: startTime, + served_profiles: ["default"], + }), + ); + expect(stopGateway("default", true)).toBe(false); + expect(isGatewayRunning("default")).toBe(true); + }, + ); + + // @lat: [[connections#Test specifications#Externally managed API keys]] + it.skipIf(process.platform !== "linux")( + "identifies a live external default gateway before rotating credentials", + () => { + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + kind: "hermes-gateway", + hermes_home: home, + gateway_state: "running", + pid: process.pid, + start_time: startTime, + served_profiles: ["default"], + }), + ); + expect(isExternallyManagedMultiplexGateway("default")).toBe(true); + expect(isExternallyManagedMultiplexGateway("scout")).toBe(false); + expect(() => + assertLocalGatewayKeyMutationAllowed("API_SERVER_KEY", "default"), + ).toThrow("managed outside Hermes One"); + expect(() => + assertLocalGatewayKeyMutationAllowed("api_server.token", "default"), + ).toThrow("managed outside Hermes One"); + expect(() => + assertLocalGatewayKeyMutationAllowed("api_server.extra.key", "default"), + ).toThrow("managed outside Hermes One"); + expect(() => + assertLocalGatewayKeyMutationAllowed("model.default", "default"), + ).not.toThrow(); + }, + ); + + it.skipIf(process.platform !== "linux")( + "rejects invalid ownership and stale multiplex state", + () => { + const file = join(home, "gateway_state.json"); + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + const state = { + kind: "hermes-gateway", + hermes_home: home, + gateway_state: "running", + pid: process.pid, + start_time: startTime, + served_profiles: ["default", "scout"], + }; + writeFileSync(file, JSON.stringify(state)); + expect(isGatewayRunning("default")).toBe(true); + expect(isGatewayRunning("other")).toBe(false); + + for (const changed of [ + { kind: "not-a-gateway" }, + { hermes_home: join(home, "other") }, + { gateway_state: "stopped" }, + { served_profiles: ["scout"] }, + ]) { + writeFileSync(file, JSON.stringify({ ...state, ...changed })); + expect(isGatewayRunning("default")).toBe(false); + } + + writeFileSync(file, JSON.stringify(state)); + vi.mocked(pidIsAliveAs).mockReturnValue(false); + expect(isGatewayRunning("default")).toBe(false); + + writeFileSync(file, "not json"); + expect(isGatewayRunning("default")).toBe(false); + }, + ); +}); + describe("chat approval normalization", () => { it("preserves an explicit safe choice subset and always includes deny", () => { expect( diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 1b0eb13ca..78461315d 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -49,6 +49,7 @@ import { getActiveProfileNameSync, } from "./utils"; import { getProfilePort } from "./gateway-ports"; +import { isMultiplexGatewayRunning } from "./gateway-liveness"; import { promptSudoPassword, promptSecretValue } from "./gatewayPrompt"; import { getSecret } from "./secrets"; import { readModels } from "./models"; @@ -3695,13 +3696,14 @@ function readPidFileEntry( export function stopGateway( profileOrForce?: string | boolean, force = false, -): void { +): boolean { const profile = typeof profileOrForce === "boolean" ? undefined : profileOrForce; const shouldForce = typeof profileOrForce === "boolean" ? profileOrForce : force; const key = profileKey(profile); - if (!shouldForce && !appStartedProfiles.has(key)) return; + if (!shouldForce && !appStartedProfiles.has(key)) return false; + if (isExternallyManagedMultiplexGateway(profile)) return false; const proc = gatewayProcesses.get(key); if (proc && isChildProcessAlive(proc)) { @@ -3731,6 +3733,7 @@ export function stopGateway( appStartedProfiles.delete(key); invalidateApiCacheFor(profile); stopTuiGatewayClient(profile); + return true; } // Python image prefixes covering both native Windows (pythonw.exe / python.exe) @@ -3751,20 +3754,55 @@ function isChildProcessAlive(proc: ChildProcess): boolean { } } +export function isExternallyManagedMultiplexGateway(profile?: string): boolean { + const targetProfile = profileKey(profile); + return ( + !appStartedProfiles.has(targetProfile) && + isMultiplexGatewayRunning(profileHome(), targetProfile, (pid) => + pidIsAliveAs(pid, GATEWAY_IMAGE_PREFIXES), + ) + ); +} + +export function assertLocalGatewayKeyMutationAllowed( + key: string, + profile?: string, +): void { + if ( + (key === "API_SERVER_KEY" || + key === "api_server.token" || + key === "api_server.extra.key") && + isExternallyManagedMultiplexGateway(profile ?? "default") + ) { + throw new Error( + "The API key belongs to a gateway managed outside Hermes One. Change it through that gateway's service manager.", + ); + } +} + export function isGatewayRunning(profile?: string): boolean { - const proc = gatewayProcesses.get(profileKey(profile)); + const targetProfile = profileKey(profile); + const proc = gatewayProcesses.get(targetProfile); if (proc && isChildProcessAlive(proc)) return true; const pid = readPidFile(profile); - if (!pid) return false; - return pidIsAliveAs(pid, GATEWAY_IMAGE_PREFIXES); + if (pid && pidIsAliveAs(pid, GATEWAY_IMAGE_PREFIXES)) return true; + + // Managed multiplex gateways publish one state file in the default home, + // not a gateway.pid in every profile they serve. + return isMultiplexGatewayRunning(profileHome(), targetProfile, (pid) => + pidIsAliveAs(pid, GATEWAY_IMAGE_PREFIXES), + ); } export function isApiReady(): boolean { return apiServerAvailable === true; } -export function isGatewayHealthy(profile?: string): Promise { - return isApiServerReady(profile); +export function isGatewayHealthy( + profile?: string, + conn: ConnectionConfig = getConnectionConfig(), +): Promise { + return isApiServerReady(profile, conn); } export function testRemoteConnection( @@ -3889,7 +3927,7 @@ async function restartGatewayLocallyOnce( const previousProcess = gatewayProcesses.get(key) ?? null; const previousStartedByApp = appStartedProfiles.has(key); const previousPidEntry = readPidFileEntry(profile); - stopGateway(profile, true); + if (!stopGateway(profile, true)) return false; const stopped = await waitForApiServerStopped( profile, stopTimeoutMs, @@ -3942,6 +3980,8 @@ export function restartGateway( // in remote/SSH mode. Cheap to check; catches IPC paths that don't // wrap their restart calls in an isRemoteMode() check. if (isRemoteMode()) return Promise.resolve(false); + if (isExternallyManagedMultiplexGateway(profile)) + return Promise.resolve(false); const key = gatewayRestartProfileKey(profile); const existing = gatewayRestartByProfile.get(key); diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 2bb20e64b..683af80cb 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -124,6 +124,7 @@ import { startGateway, startGatewayDetailed, stopGateway, + assertLocalGatewayKeyMutationAllowed, isGatewayRunning, testRemoteConnection, restartGateway, @@ -1100,6 +1101,7 @@ export function registerIpcHandlers(context: IpcContext): void { await sshSetEnvValue(conn.ssh, key, value, profile); return true; } + assertLocalGatewayKeyMutationAllowed(key, profile); setEnvValue(key, value, profile); // Restart gateway so it picks up the new API key. // The earlier condition had a precedence bug — @@ -1135,6 +1137,7 @@ export function registerIpcHandlers(context: IpcContext): void { await sshSetConfigValue(conn.ssh, key, value, profile); return true; } + assertLocalGatewayKeyMutationAllowed(key, profile); setConfigValue(key, value, profile); return true; }, @@ -1338,6 +1341,9 @@ export function registerIpcHandlers(context: IpcContext): void { ipcMain.handle( "generate-api-server-key", async (_event, profile?: string) => { + // This action also writes the default home's key for named profiles. + // Refuse before either write when a service owns the live gateway. + assertLocalGatewayKeyMutationAllowed("API_SERVER_KEY", "default"); const { randomUUID } = await import("crypto"); const key = `desk-${randomUUID()}`; // Write to both the active profile .env and the default .env so the @@ -2118,7 +2124,11 @@ export function registerIpcHandlers(context: IpcContext): void { } // No profile argument → stops the active profile's gateway, leaving any // other profiles' gateways running. - stopGateway(undefined, true); + if (!stopGateway(undefined, true)) { + throw new Error( + "This gateway is managed outside Hermes One. Stop it with the Hermes CLI or its service manager.", + ); + } return true; }); ipcMain.handle("restart-gateway", async (_event, profile?: string) => { diff --git a/src/main/profiles.gateway-status.test.ts b/src/main/profiles.gateway-status.test.ts new file mode 100644 index 000000000..bb9f041ff --- /dev/null +++ b/src/main/profiles.gateway-status.test.ts @@ -0,0 +1,59 @@ +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./installer", () => ({ + HERMES_HOME: process.env.HERMES_ONE_PROFILE_TEST_HOME, +})); +vi.mock("./utils", async (importOriginal) => ({ + ...(await importOriginal()), + pidIsAliveAs: vi.fn(() => true), +})); + +describe("local profile gateway status", () => { + // @lat: [[connections#Test specifications#Multiplex status in profile lists]] + it.skipIf(process.platform !== "linux")( + "reports only the multiplex default profile online in the status bar list", + async () => { + const home = mkdtempSync(join(tmpdir(), "hermes-one-profile-status-")); + process.env.HERMES_ONE_PROFILE_TEST_HOME = home; + try { + mkdirSync(join(home, "profiles", "scout"), { recursive: true }); + writeFileSync(join(home, "active_profile"), "default"); + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + gateway_state: "running", + kind: "hermes-gateway", + hermes_home: home, + pid: process.pid, + start_time: startTime, + served_profiles: ["default", "scout"], + }), + ); + const { listProfiles } = await import("./profiles"); + const profiles = await listProfiles(); + expect( + profiles.map(({ id, gatewayRunning }) => ({ id, gatewayRunning })), + ).toEqual([ + { id: "default", gatewayRunning: true }, + { id: "scout", gatewayRunning: false }, + ]); + } finally { + delete process.env.HERMES_ONE_PROFILE_TEST_HOME; + rmSync(home, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/src/main/profiles.ts b/src/main/profiles.ts index d7f92a611..5a92d0954 100644 --- a/src/main/profiles.ts +++ b/src/main/profiles.ts @@ -19,6 +19,7 @@ import { } from "./utils"; import { HIDDEN_SUBPROCESS_OPTIONS } from "./process-options"; import { readProfileMeta, defaultColorForName } from "./profile-meta"; +import { isMultiplexGatewayRunning } from "./gateway-liveness"; const PROFILES_DIR = join(HERMES_HOME, "profiles"); @@ -142,7 +143,10 @@ async function countSkills(profilePath: string): Promise { } } -async function isGatewayRunning(profilePath: string): Promise { +async function isGatewayRunning( + profilePath: string, + profile: string, +): Promise { const pidFile = join(profilePath, "gateway.pid"); try { const raw = (await fs.readFile(pidFile, "utf-8")).trim(); @@ -153,11 +157,15 @@ async function isGatewayRunning(profilePath: string): Promise { : parseInt(raw, 10); const pid = typeof parsed === "number" && Number.isFinite(parsed) ? parsed : NaN; - if (isNaN(pid)) return false; - return pidIsAliveAs(pid, ["python", "pythonw"]); + if (!isNaN(pid) && pidIsAliveAs(pid, ["python", "pythonw"])) { + return true; + } } catch { - return false; + // A managed multiplexer has no per-profile PID file. } + return isMultiplexGatewayRunning(HERMES_HOME, profile, (pid) => + pidIsAliveAs(pid, ["python", "pythonw"]), + ); } async function getActiveProfileName(): Promise { @@ -190,7 +198,7 @@ export async function listProfiles(): Promise { fileExists(join(HERMES_HOME, ".env")), fileExists(join(HERMES_HOME, "SOUL.md")), countSkills(HERMES_HOME), - isGatewayRunning(HERMES_HOME), + isGatewayRunning(HERMES_HOME, "default"), readProfileMeta("default"), ]); @@ -233,7 +241,7 @@ export async function listProfiles(): Promise { fileExists(join(profilePath, ".env")), fileExists(join(profilePath, "SOUL.md")), countSkills(profilePath), - isGatewayRunning(profilePath), + isGatewayRunning(profilePath, name), readProfileMeta(name), ]); From a0581ebdf9706004240b0ec7f49b066255c1901e Mon Sep 17 00:00:00 2001 From: MrTheSoulz <5899335+MrTheSoulz@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:16:09 +0100 Subject: [PATCH 2/2] fix: honor configured default gateway port and home identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the default profile’s explicit API port, canonicalize equivalent Hermes home paths, and guard the platform API key config path for service-owned gateways. --- lat.md/connections.md | 8 +++++++ src/main/gateway-liveness.ts | 5 +++-- src/main/gateway-ports.test.ts | 24 ++++++++++++++++++++ src/main/gateway-ports.ts | 4 ++-- src/main/hermes.test.ts | 41 +++++++++++++++++++++++++++++++++- src/main/hermes.ts | 3 ++- 6 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/main/gateway-ports.test.ts diff --git a/lat.md/connections.md b/lat.md/connections.md index 5ce5ea745..a25f053b5 100644 --- a/lat.md/connections.md +++ b/lat.md/connections.md @@ -105,6 +105,10 @@ A running default-home gateway reports Local online in Settings without `gateway Settings checks API reachability for the default Local gateway after confirming its process is live. Named profiles keep PID-based status so a read-only status probe never invokes their port allocator or rewrites config. +### Default API port selection + +The default profile's Local API uses its explicitly configured `platforms.api_server.extra.port` when present, otherwise the historical default port; resolving it does not modify configuration. + ### Externally managed gateway controls Hermes One never claims to stop a multiplex gateway owned by a service or other process; its Stop action reports that the gateway must be managed by its owner. @@ -113,6 +117,10 @@ Hermes One never claims to stop a multiplex gateway owned by a service or other An API key cannot be rotated in Hermes One while another process owns the live default gateway, because that process would keep using its old key until its own supervisor restarts it. +### Equivalent gateway home paths + +An externally managed gateway's recorded home and the desktop's home can differ lexically through a symlink while naming the same directory; status and control ownership must recognize that equivalence. + ### Multiplex status in profile lists The profile list backing the status bar and agent screens recognizes the default home's live multiplexer for its default profile only; named profiles without a dedicated API listener remain offline. diff --git a/src/main/gateway-liveness.ts b/src/main/gateway-liveness.ts index ab07cd4cc..54892cd48 100644 --- a/src/main/gateway-liveness.ts +++ b/src/main/gateway-liveness.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "fs"; +import { readFileSync, realpathSync } from "fs"; import { join } from "path"; /** Match the default home's managed gateway, not a reused PID or a named profile's missing API port. */ @@ -22,7 +22,8 @@ export function isMultiplexGatewayRunning( }; if ( state.kind !== "hermes-gateway" || - state.hermes_home !== home || + typeof state.hermes_home !== "string" || + realpathSync(state.hermes_home) !== realpathSync(home) || state.gateway_state !== "running" || typeof state.pid !== "number" || !Number.isSafeInteger(state.pid) || diff --git a/src/main/gateway-ports.test.ts b/src/main/gateway-ports.test.ts new file mode 100644 index 000000000..eaa131a0a --- /dev/null +++ b/src/main/gateway-ports.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConfigValue: vi.fn(), + setConfigValue: vi.fn(), +})); + +vi.mock("./config", () => mocks); +vi.mock("./installer", () => ({ HERMES_HOME: "/unused-hermes-home" })); +vi.mock("./utils", () => ({ + normalizeProfileName: (profile?: string) => + profile === "default" ? undefined : profile, +})); + +import { getProfilePort } from "./gateway-ports"; + +describe("default Local API port", () => { + // @lat: [[connections#Test specifications#Default API port selection]] + it("uses its configured port without changing config", () => { + mocks.getConfigValue.mockReturnValue("18765"); + expect(getProfilePort("default")).toBe(18765); + expect(mocks.setConfigValue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/gateway-ports.ts b/src/main/gateway-ports.ts index dad968123..051690915 100644 --- a/src/main/gateway-ports.ts +++ b/src/main/gateway-ports.ts @@ -85,7 +85,7 @@ function allocateFreePort(profile: string): number { * Resolve the api_server port the desktop should bind `profile`'s gateway to. * * config.yaml is the single source of truth: - * - default profile → pinned to {@link DEFAULT_API_SERVER_PORT}. + * - default profile → its explicitly configured port, else {@link DEFAULT_API_SERVER_PORT}. * - named profile with no configured port → allocate a free port and persist * it (the api_server block is written by ensureApiServerConfig). * - named profile whose configured port collides with the default or another @@ -97,7 +97,7 @@ function allocateFreePort(profile: string): number { */ export function getProfilePort(profile?: string): number { const name = normalizeProfileName(profile); // undefined => default - if (!name) return DEFAULT_API_SERVER_PORT; + if (!name) return readConfiguredPort(undefined) ?? DEFAULT_API_SERVER_PORT; const configured = readConfiguredPort(name); if (configured !== null) { diff --git a/src/main/hermes.test.ts b/src/main/hermes.test.ts index 2c3aba910..f2e957394 100644 --- a/src/main/hermes.test.ts +++ b/src/main/hermes.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -222,6 +228,12 @@ describe("local gateway status", () => { expect(() => assertLocalGatewayKeyMutationAllowed("api_server.extra.key", "default"), ).toThrow("managed outside Hermes One"); + expect(() => + assertLocalGatewayKeyMutationAllowed( + "platforms.api_server.extra.key", + "default", + ), + ).toThrow("managed outside Hermes One"); expect(() => assertLocalGatewayKeyMutationAllowed("model.default", "default"), ).not.toThrow(); @@ -266,6 +278,33 @@ describe("local gateway status", () => { expect(isGatewayRunning("default")).toBe(false); }, ); + + // @lat: [[connections#Test specifications#Equivalent gateway home paths]] + it.skipIf(process.platform !== "linux")( + "recognizes the same gateway home through a symlink", + () => { + const alias = join(home, "alias"); + symlinkSync(home, alias, "dir"); + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf-8"); + const startTime = Number( + stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + ); + writeFileSync( + join(home, "gateway_state.json"), + JSON.stringify({ + kind: "hermes-gateway", + hermes_home: home, + gateway_state: "running", + pid: process.pid, + start_time: startTime, + served_profiles: ["default"], + }), + ); + vi.mocked(profileHome).mockReturnValue(alias); + expect(isGatewayRunning("default")).toBe(true); + expect(isExternallyManagedMultiplexGateway("default")).toBe(true); + }, + ); }); describe("chat approval normalization", () => { diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 78461315d..347e2339a 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -3771,7 +3771,8 @@ export function assertLocalGatewayKeyMutationAllowed( if ( (key === "API_SERVER_KEY" || key === "api_server.token" || - key === "api_server.extra.key") && + key === "api_server.extra.key" || + key === "platforms.api_server.extra.key") && isExternallyManagedMultiplexGateway(profile ?? "default") ) { throw new Error(