Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lat.md/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,34 @@ 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.

### 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.

### 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.

### 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.

### 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.
Expand Down
25 changes: 25 additions & 0 deletions src/main/connection-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
});
});
12 changes: 11 additions & 1 deletion src/main/connection-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return {
health,
latencyMs: elapsed(startedAt),
Expand Down
54 changes: 54 additions & 0 deletions src/main/gateway-liveness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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. */
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" ||
typeof state.hermes_home !== "string" ||
realpathSync(state.hermes_home) !== realpathSync(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;
}
}
24 changes: 24 additions & 0 deletions src/main/gateway-ports.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 2 additions & 2 deletions src/main/gateway-ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading
Loading