Skip to content
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"src/lib/cli/terminal-style.ts": 43,
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 87,
"src/lib/core/shell-quote.ts": 26,
"src/lib/core/shell-quote.ts": 27,
"src/lib/core/url-utils.ts": 28,
"src/lib/core/wait.ts": 35,
"src/lib/credentials/store.ts": 46,
Expand Down
87 changes: 87 additions & 0 deletions src/lib/actions/sandbox/channel-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,42 @@ function hermesSessionProbeOutput(options: {
].join("\n");
}

const HERMES_DEFAULT_SESSION_DIR = "/sandbox/.hermes/platforms/whatsapp/session";
const HERMES_DASHBOARD_SESSION_DIR =
"/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session";

function hermesConfigYaml(sessionPath: string): string {
return [
"platforms:",
" whatsapp:",
" extra:",
` session_path: ${JSON.stringify(sessionPath)}`,
].join("\n");
}

function hermesExec(options: {
readonly configYaml?: string;
readonly credsDirs: readonly string[];
}) {
const hasCreds = (credsFile: string) =>
options.credsDirs.some((dir) => credsFile === `${dir}/creds.json`);
return vi.fn((_sandbox: string, command: string, _timeoutMs?: number) => {
if (command.startsWith("cat ")) {
return options.configYaml === undefined
? { status: 1, stdout: "", stderr: "cat: no such file" }
: { status: 0, stdout: options.configYaml, stderr: "" };
}
return {
status: 0,
stdout: hermesSessionProbeOutput({
gatewaySessionCreds: hasCreds(/gateway='([^']*)'/.exec(command)?.[1] ?? ""),
dashboardSessionCreds: hasCreds(/dashboard='([^']*)'/.exec(command)?.[1] ?? ""),
}),
stderr: "",
};
});
}

describe("showSandboxChannelStatus (whatsapp)", () => {
it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => {
const stdout = waStatusJson({
Expand Down Expand Up @@ -281,6 +317,57 @@ describe("showSandboxChannelStatus (whatsapp)", () => {
expect(session?.severity).toBe("ok");
});

it("clears the session-path split after the documented session_path repair (#8718)", async () => {
const exec = hermesExec({
configYaml: hermesConfigYaml(HERMES_DASHBOARD_SESSION_DIR),
credsDirs: [HERMES_DASHBOARD_SESSION_DIR],
});
const { deps, out_lines } = makeDeps({
exec,
agentName: "hermes",
sandbox: entry(["whatsapp"], [], {}, "hermes"),
});
const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" });
const signals = result && "report" in result ? result.report.signals : [];
const dump = out_lines.join("\n");
expect(result && "report" in result && result.report.verdict).not.toBe("unpaired");
expect(signals.find((signal) => signal.label === "Session location")?.severity).toBe("ok");
expect(signals.find((signal) => signal.label === "Session path override")?.severity).toBe(
"info",
);
expect(dump).not.toContain("the Hermes gateway session path is empty");
expect(exec.mock.calls.map((call) => String(call[1] ?? "")).join("\n")).toContain(
`gateway='${HERMES_DASHBOARD_SESSION_DIR}/creds.json'`,
);
Comment thread
apurvvkumaria marked this conversation as resolved.
Resolved
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

it("keeps the default session path when the configured session path is unsupported (#8718)", async () => {
const exec = hermesExec({
configYaml: hermesConfigYaml("/etc/hermes/session"),
credsDirs: [HERMES_DASHBOARD_SESSION_DIR],
});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
const { deps } = makeDeps({
exec,
agentName: "hermes",
sandbox: entry(["whatsapp"], [], {}, "hermes"),
});
let threw: Error | null = null;
try {
await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" });
} catch (err) {
threw = err as Error;
} finally {
exitSpy.mockRestore();
}
const commands = exec.mock.calls.map((call) => String(call[1] ?? "")).join("\n");
expect(threw?.message).toBe("process.exit(1)");
expect(commands).toContain(`gateway='${HERMES_DEFAULT_SESSION_DIR}/creds.json'`);
expect(commands).not.toContain("/etc/hermes/session");
});

it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => {
// Regression guard: `channels stop whatsapp` deliberately drops the
// bridge and preset until the operator runs `channels start`. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,71 @@ describe("evaluateWhatsappDiagnostics", () => {
expect(session?.hint).toBe("use one active WhatsApp bridge for the paired account");
});

it("accepts credentials found through the configured session path (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: null,
sessionLocations: {
gatewaySessionCreds: true,
dashboardSessionCreds: true,
gatewaySessionPathSource: "config",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
const override = report.signals.find((s) => s.label === "Session path override");
expect(report.verdict).toBe("unknown");
expect(session?.severity).toBe("ok");
expect(session?.detail).toBe(
"the configured Hermes WhatsApp session path contains credentials",
);
expect(override?.severity).toBe("info");
expect(override?.detail).toContain("platforms.whatsapp.extra.session_path");
});

it("stops repeating the repair command when the configured session path is empty (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: false,
sessionLocations: {
gatewaySessionCreds: false,
dashboardSessionCreds: true,
gatewaySessionPathSource: "config",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
expect(report.verdict).toBe("unpaired");
expect(session?.severity).toBe("warn");
expect(session?.detail).toBe(
"the configured Hermes WhatsApp session path has no WhatsApp credentials",
);
expect(report.hints.join(" ")).not.toContain("--config-accept-new-path");
expect(report.hints.join(" ")).toContain("hermes whatsapp");
});

it("warns when the configured session path is not a supported sandbox path (#8718)", () => {
const report = evaluateWhatsappDiagnostics(
baseInput({
agent: "hermes",
paired: false,
sessionLocations: {
gatewaySessionCreds: false,
dashboardSessionCreds: true,
gatewaySessionPathSource: "unsupported",
},
}),
);
const session = report.signals.find((s) => s.label === "Session location");
const override = report.signals.find((s) => s.label === "Session path override");
expect(session?.severity).toBe("warn");
expect(session?.detail).toMatch(/dashboard-home has WhatsApp credentials/);
expect(override?.severity).toBe("warn");
expect(override?.detail).toContain("not a supported session path");
});

it("returns idle when paired with a live WebSocket but no inbound event observed", () => {
// This is the exact #4386 shape: pairing is fine, WebSocket is up, but
// lastInboundAt is still null. We MUST NOT report this as healthy.
Expand Down
43 changes: 42 additions & 1 deletion src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export type WhatsappProbeInput = {
export type WhatsappSessionLocations = {
gatewaySessionCreds: boolean | null;
dashboardSessionCreds: boolean | null;
gatewaySessionPathSource?: "default" | "config" | "unsupported";
};

/**
Expand Down Expand Up @@ -186,7 +187,8 @@ function hermesDashboardOnlySession(input: WhatsappProbeInput): boolean {
return (
input.agent === "hermes" &&
input.sessionLocations?.gatewaySessionCreds === false &&
input.sessionLocations.dashboardSessionCreds === true
input.sessionLocations.dashboardSessionCreds === true &&
input.sessionLocations.gatewaySessionPathSource !== "config"
);
}

Expand All @@ -195,6 +197,20 @@ function sessionLocationSignal(input: WhatsappProbeInput): DiagnosticSignal | nu
if (!locations) return null;
const gateway = locations.gatewaySessionCreds;
const dashboard = locations.dashboardSessionCreds;
if (locations.gatewaySessionPathSource === "config" && gateway !== null) {
return gateway
? {
label: "Session location",
severity: "ok",
detail: "the configured Hermes WhatsApp session path contains credentials",
}
: {
label: "Session location",
severity: "warn",
detail: "the configured Hermes WhatsApp session path has no WhatsApp credentials",
hint: "pair WhatsApp again, or set `platforms.whatsapp.extra.session_path` to the session path that holds the credentials",
};
}
if (gateway === false && dashboard === true) {
return {
label: "Session location",
Expand Down Expand Up @@ -233,6 +249,29 @@ function sessionLocationSignal(input: WhatsappProbeInput): DiagnosticSignal | nu
};
}

function sessionPathOverrideSignal(input: WhatsappProbeInput): DiagnosticSignal | null {
const source = input.sessionLocations?.gatewaySessionPathSource;
if (source === "config") {
return {
label: "Session path override",
severity: "info",
detail:
"the gateway session check used the path set in `platforms.whatsapp.extra.session_path`",
hint: "remove the override after Hermes uses one WhatsApp session path for dashboard pairing and gateway startup",
};
}
if (source === "unsupported") {
return {
label: "Session path override",
severity: "warn",
detail:
"`platforms.whatsapp.extra.session_path` is not a supported session path, so the gateway session check used the default path",
hint: "set `platforms.whatsapp.extra.session_path` to an absolute path under `/sandbox/.hermes` that has no `.` or `..` segment",
};
}
return null;
}

function websocketSignal(input: WhatsappProbeInput): DiagnosticSignal {
if (input.heartbeatParseError) {
return {
Expand Down Expand Up @@ -513,10 +552,12 @@ function buildHints(verdict: WhatsappVerdict, input: WhatsappProbeInput): string

export function evaluateWhatsappDiagnostics(input: WhatsappProbeInput): WhatsappDiagnosticReport {
const sessionSignal = sessionLocationSignal(input);
const overrideSignal = sessionPathOverrideSignal(input);
const signals: DiagnosticSignal[] = [
configCoverageSignal(input),
pairingSignal(input),
...(sessionSignal ? [sessionSignal] : []),
...(overrideSignal ? [overrideSignal] : []),
bridgeProcessSignal(input),
websocketSignal(input),
inboundSignal(input),
Expand Down
118 changes: 118 additions & 0 deletions src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,42 @@ function hermesSessionProbeOutput(options: {
].join("\n");
}

const HERMES_DEFAULT_SESSION_DIR = "/sandbox/.hermes/platforms/whatsapp/session";
const HERMES_DASHBOARD_SESSION_DIR =
"/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session";

function hermesConfigYaml(sessionPath: unknown): string {
return [
"platforms:",
" whatsapp:",
" extra:",
` session_path: ${JSON.stringify(sessionPath)}`,
].join("\n");
}

function hermesExec(options: {
readonly configYaml?: string;
readonly credsDirs: readonly string[];
}) {
const hasCreds = (credsFile: string) =>
options.credsDirs.some((dir) => credsFile === `${dir}/creds.json`);
return vi.fn((_sandbox: string, command: string, _timeout: number): ExecResult => {
if (command.startsWith("cat ")) {
return options.configYaml === undefined
? { status: 1, stdout: "", stderr: "cat: no such file" }
: { status: 0, stdout: options.configYaml, stderr: "" };
}
return {
status: 0,
stdout: hermesSessionProbeOutput({
gatewaySessionCreds: hasCreds(/gateway='([^']*)'/.exec(command)?.[1] ?? ""),
dashboardSessionCreds: hasCreds(/dashboard='([^']*)'/.exec(command)?.[1] ?? ""),
}),
stderr: "",
};
});
}

const HEALTHY_WA: WaFixture = {
configured: true,
statusState: "linked",
Expand Down Expand Up @@ -421,6 +457,88 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => {
expect(command).not.toMatch(/(^|[;&|]\s*)(cat|grep|find|ls)\b/);
});

it.each([
{
label: "path outside the Hermes config directory",
sessionPath: "/etc/hermes/session",
},
{
label: "path with a parent-directory segment",
sessionPath: "/sandbox/.hermes/../../etc/session",
},
{
label: "path carrying shell syntax",
sessionPath: "/sandbox/.hermes/session'; touch /tmp/pwned; '",
},
{
label: "non-string value",
sessionPath: 42,
},
])("keeps the default gateway session path for an unsupported session_path: $label (#8718)", ({
sessionPath,
}) => {
const exec = hermesExec({
configYaml: hermesConfigYaml(sessionPath),
credsDirs: [HERMES_DASHBOARD_SESSION_DIR],
});
const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(
context({ ...BASE_INPUTS, agent: "hermes" }),
);
const report = reportOf(result);
const probeCommands = exec.mock.calls
.map((call) => String(call[1] ?? ""))
.filter((command) => !command.startsWith("cat "));
expect(report?.verdict).toBe("unpaired");
expect(report?.signals.find((s) => s.label === "Session path override")?.severity).toBe("warn");
expect(probeCommands).toHaveLength(1);
expect(probeCommands[0]).toContain(`gateway='${HERMES_DEFAULT_SESSION_DIR}/creds.json'`);
expect(probeCommands[0]).not.toContain(String(sessionPath));
});

it("reads the Hermes config only when the default session path is empty (#8718)", () => {
const exec = hermesExec({
configYaml: hermesConfigYaml(HERMES_DASHBOARD_SESSION_DIR),
credsDirs: [HERMES_DEFAULT_SESSION_DIR],
});
const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(
context({ ...BASE_INPUTS, agent: "hermes" }),
);
const report = reportOf(result);
expect(exec.mock.calls.map((call) => String(call[1] ?? ""))).toHaveLength(1);
expect(report?.signals.find((s) => s.label === "Session path override")).toBeUndefined();
});

it("keeps unrelated Hermes config values out of the report (#8718)", () => {
const exec = hermesExec({
configYaml: [
"model:",
' api_key: "sk-do-not-leak-this"',
"platforms:",
" whatsapp:",
" extra:",
` session_path: ${JSON.stringify(HERMES_DASHBOARD_SESSION_DIR)}`,
].join("\n"),
credsDirs: [HERMES_DASHBOARD_SESSION_DIR],
});
const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(
context({ ...BASE_INPUTS, agent: "hermes" }),
);
expect(stringifyReport(result)).not.toContain("sk-do-not-leak-this");
});
Comment thread
apurvvkumaria marked this conversation as resolved.
Resolved

it("falls back to the default session path when the Hermes config cannot be read (#8718)", () => {
const exec = hermesExec({ credsDirs: [HERMES_DASHBOARD_SESSION_DIR] });
const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })(
context({ ...BASE_INPUTS, agent: "hermes" }),
);
const report = reportOf(result);
expect(report?.verdict).toBe("unpaired");
expect(report?.signals.find((s) => s.label === "Session location")?.hint).toContain(
"platforms.whatsapp.extra.session_path",
);
expect(report?.signals.find((s) => s.label === "Session path override")).toBeUndefined();
});

it("does not treat a Hermes gateway session file as live inbound health", () => {
const exec = makeExec({
status: 0,
Expand Down
Loading
Loading