electron/diagnostics.test.mjs
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { linkSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const require = createRequire(import.meta.url);
const {
appendDesktopCrashRecord,
buildDiagnosticsReport,
decodeLogTail,
diagnosticsFileName,
formatDesktopCrashRecord,
installDesktopCrashListeners,
readSafeLogTail,
redactSecretsInLine,
CREDENTIAL_ENV_NAMES,
} = require("./diagnostics.mjs");
// The desktop credential list is a hand copy of server/credential-env.ts WORKSPACE_CREDENTIAL_ENV; this test is the drift alarm.
describe("credential env parity with server/credential-env.ts", () => {
it("matches WORKSPACE_CREDENTIAL_ENV exactly", () => {
const config = readFileSync(new URL("../server/credential-env.ts", import.meta.url), "utf8");
const match = config.match(/WORKSPACE_CREDENTIAL_ENV = \[([\s\S]*?)\] as const/);
expect(match).not.toBeNull();
const names = [...match[1].matchAll(/"([A-Z0-9_]+)"/g)].map((m) => m[1]);
expect(CREDENTIAL_ENV_NAMES).toEqual(names);
});
});
describe("buildDiagnosticsReport", () => {
const appInfo = {
version: "0.1.27",
platform: "darwin",
arch: "arm64",
electron: "43.4.0",
node: "24.0.0",
packaged: true,
uptimeSeconds: 42,
};
it("renders app facts and a sorted config summary", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {
xai: { configured: true },
box: { configured: false },
rooms: { turnTimeoutMinutes: 5 },
},
logTail: "",
});
expect(report).toContain("version=0.1.27");
expect(report).toContain("platform=darwin");
expect(report).toContain("arch=arm64");
expect(report).toContain("xai.configured=true");
expect(report).toContain("box.configured=false");
expect(report).toContain("rooms.turnTimeoutMinutes=5");
expect(report).toContain("(no desktop crash events available)");
expect(report).toContain("(server log unavailable)");
expect(report).toContain("(updater log unavailable)");
});
it("includes updater diagnostics separately and redacts signed redirects and headers", () => {
const report = buildDiagnosticsReport({
appInfo,
logTail: "server ready",
updaterLogTail: [
"[info] Downloading version 0.1.61",
"[error] GET https://release-assets.example.test/update.zip?X-Amz-Credential=private-account&X-Amz-Signature=signed-secret&jwt=private-jwt HTTP 403",
'headers {"authorization":"Basic dXNlcjpwYXNzd29yZA==","apiKey":"opaque-private-key"}',
"Set-Cookie: session=private-session; Secure; HttpOnly",
].join("\n"),
});
expect(report).toContain("## Updater log tail — credentials and URL parameters auto-masked");
expect(report).toContain("Downloading version 0.1.61");
expect(report).toContain("https://release-assets.example.test/update.zip?«redacted URL parameters» HTTP 403");
for (const secret of ["private-account", "signed-secret", "private-jwt", "dXNlcjpwYXNzd29yZA==", "opaque-private-key", "private-session"]) {
expect(report).not.toContain(secret);
}
expect(report.indexOf("server ready")).toBeLessThan(report.indexOf("Downloading version 0.1.61"));
});
it.each(["", null, undefined])("handles an absent updater log (%s)", (updaterLogTail) => {
expect(buildDiagnosticsReport({ appInfo, updaterLogTail })).toContain("(updater log unavailable)");
});
it("removes URL userinfo, fragments, and unfamiliar query capabilities without hiding public paths", () => {
const line = "GET https://private-user:private-password@updates.example.test/update.zip?%73ig=encoded-secret#fragment-secret\n"
+ "GET https://updates.example.test/latest-mac.yml\n"
+ "GET https://updates.example.test/update.zip#private-fragment";
const redacted = redactSecretsInLine(line);
expect(redacted).toContain("https://«redacted credentials»@updates.example.test/update.zip?«redacted URL parameters»");
expect(redacted).toContain("https://updates.example.test/latest-mac.yml");
for (const secret of ["private-user", "private-password", "encoded-secret", "fragment-secret", "private-fragment"]) {
expect(redacted).not.toContain(secret);
}
});
it("masks JSON credential fields and cookies in updater HTTP dumps", () => {
const redacted = redactSecretsInLine([
'{"OMB_COMPOSIO_BROKER_TOKEN":"opaque-broker-value","password":"opaque-password-value"}',
'{"Cookie":"session=private-cookie; tracking=private-tracker"}',
"Cookie: session=private-session; tracking=private-tracking",
].join("\n"));
for (const secret of ["opaque-broker-value", "opaque-password-value", "private-cookie", "private-tracker", "private-session", "private-tracking"]) {
expect(redacted).not.toContain(secret);
}
});
it("masks Groq keys in diagnostic log exports while retaining useful status", () => {
const key = `gsk_${"a".repeat(48)}`;
const otherKey = `gsk_${"b".repeat(48)}`;
const workspaceKey = "opaque-groq-fixture-key";
const report = buildDiagnosticsReport({
appInfo,
configSummary: { groq: { configured: true } },
logTail: `Groq HTTP 401: invalid key ${key}\nspawn env OMB_GROQ_API_KEY=${workspaceKey}`,
updaterLogTail: `upstream rejected ${otherKey}`,
});
for (const secret of [key, otherKey, workspaceKey]) expect(report).not.toContain(secret);
expect(report).toContain("groq.configured=true");
expect(report).toContain("Groq HTTP 401");
});
it("masks long-term and temporary AWS access key ids", () => {
const longTerm = `AKIA${"ABCDEFGHIJKLMNOP"}`;
const temporary = `ASIA${"QRSTUVWXYZ234567"}`;
const redacted = redactSecretsInLine(`aws ${longTerm} then sts ${temporary} done`);
for (const id of [longTerm, temporary]) expect(redacted).not.toContain(id);
expect(redacted).toContain("done");
});
it("includes privacy-safe desktop crash metadata separately from the server log", () => {
const desktopLogTail =
"[2026-08-31T11:00:00.000Z] event=render-process-gone surface=main-window reason=crashed exitCode=5";
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
desktopLogTail,
logTail: "server ready",
});
expect(report).toContain("## Desktop crash events — privacy-safe metadata only");
expect(report).toContain(desktopLogTail);
expect(report.indexOf(desktopLogTail)).toBeLessThan(report.indexOf("server ready"));
});
it("redacts desktop crash lines again before exporting them", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
desktopLogTail: "event=future-crash token=abcdefghijklmnop",
logTail: "",
});
expect(report).not.toContain("abcdefghijklmnop");
expect(report).toContain("token=«redacted");
});
it("drops strings, non-scalars and credential-shaped summary values", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {
xai: { key: "xai-real-secret" },
composio: { apiKey: "ak_live_abcdef123456789" },
vps: { sshAlias: "" },
profile: { name: "Ada" },
instances: [{ driver: "claudeAgent", environment: { TOKEN: "hunter2" } }],
note: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
},
logTail: "",
});
expect(report).not.toContain("xai-real-secret");
expect(report).not.toContain("ak_live_abcdef123456789");
expect(report).not.toContain("hunter2");
expect(report).not.toContain("driver");
expect(report).not.toContain("environment");
expect(report).not.toContain("profile.name=");
expect(report).not.toContain("Ada");
expect(report).not.toContain("note=");
});
it("never includes an absolute log path in the report heading", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
logTail: "server ready",
logPath: "/Users/ada/Library/Logs/OpenMausBot/server.log",
});
expect(report).toContain("## Server log tail");
expect(report).not.toContain("/Users/ada");
});
it.each(CREDENTIAL_ENV_NAMES)("masks any value riding on %s in the log tail", (name) => {
const value = "s3cr3t-value-123456";
const line = `spawn env ${name}=${value} ready`;
const report = buildDiagnosticsReport({ appInfo, configSummary: {}, logTail: line });
expect(report).not.toContain(value);
expect(redactSecretsInLine(line)).toBe(`spawn env ${name}=«redacted ${value.length} chars» ready`);
});
it("masks generic key=value secrets and content-shaped tokens in the log tail", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
logTail: [
'config {"apiKey":"sk-proj-abcdefghijklmnop"}',
"Authorization: Bearer abcdefghijklmnop",
"password=hunter2000",
].join("\n"),
});
expect(report).not.toContain("sk-proj-abcdefghijklmnop");
expect(report).not.toContain("abcdefghijklmnop");
expect(report).not.toContain("hunter2000");
expect(report).toContain("«redacted");
});
it.each(["Bearer abcdefghijklmnop", "Basic dXNlcjpwYXNzd29yZA=="])(
"masks the full Authorization credential for %s",
(authorization) => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
logTail: `request Authorization: ${authorization}`,
});
expect(report).not.toContain(authorization);
expect(report).not.toContain(authorization.split(" ")[1]);
expect(report).toContain("Authorization=«redacted");
},
);
it("masks a multiline PEM private key as one value", () => {
const report = buildDiagnosticsReport({
appInfo,
configSummary: {},
logTail: [
"loading credential",
"-----BEGIN PRIVATE KEY-----",
"super-secret-line-one",
"super-secret-line-two",
"-----END PRIVATE KEY-----",
"ready",
].join("\n"),
});
expect(report).not.toContain("super-secret-line-one");
expect(report).not.toContain("super-secret-line-two");
expect(report).toContain("«redacted private key»");
});
it("leaves ordinary log lines untouched", () => {
const line = "[2026-08-22T20:00:00.000Z] [out] fork server/index.js port=8799 spawned pid=4242";
expect(redactSecretsInLine(line)).toBe(line);
});
it("handles an empty or missing log gracefully", () => {
for (const logTail of ["", null, undefined]) {
const report = buildDiagnosticsReport({ appInfo, configSummary: {}, logTail });
expect(report).toContain("(server log unavailable)");
expect(report.endsWith("\n")).toBe(true);
}
});
it("keeps long prose lines that merely mention a key by name", () => {
const line = "user asked whether the XAI_API_KEY variable needs to be set manually";
expect(redactSecretsInLine(line)).toBe(line);
});
});
describe("formatDesktopCrashRecord", () => {
it("records renderer and child failures without accepting arbitrary fields", () => {
expect(
formatDesktopCrashRecord({
kind: "renderer",
surface: "main-window",
reason: "crashed",
exitCode: 9,
url: "https://example.test/private?token=secret",
}),
).toBe("event=render-process-gone surface=main-window reason=crashed exitCode=9");
expect(
formatDesktopCrashRecord({
kind: "child",
type: "GPU",
reason: "oom",
exitCode: 34,
commandLine: "--password=secret",
}),
).toBe("event=child-process-gone type=GPU reason=oom exitCode=34");
});
it("omits normal clean exits", () => {
expect(formatDesktopCrashRecord({ kind: "renderer", reason: "clean-exit" })).toBeNull();
expect(formatDesktopCrashRecord({ kind: "child", reason: "clean-exit" })).toBeNull();
});
it("reduces a fatal main-process error to its type without reading its message or stack", () => {
const error = new TypeError("secret user text from C:\\Users\\Ada\\private.txt");
error.stack = [
"TypeError: secret user text from C:\\Users\\Ada\\private.txt",
"secret-client.ts:1:1",
" at boot (file:///C:/Users/Ada/OpenMausBot/electron/main.mjs:412:7)",
].join("\n");
const record = formatDesktopCrashRecord({ kind: "main", origin: "unhandledRejection", error });
expect(record).toBe("event=main-process-failure origin=unhandledRejection error=TypeError");
expect(record).not.toContain("Ada");
expect(record).not.toContain("secret user text");
expect(record).not.toContain("secret-client");
});
it("fails closed for unknown Electron metadata", () => {
expect(
formatDesktopCrashRecord({
kind: "renderer",
surface: "private-window-name",
reason: "token=super-secret",
exitCode: "5; password=secret",
}),
).toBe("event=render-process-gone surface=auxiliary reason=unknown exitCode=unknown");
expect(formatDesktopCrashRecord({ kind: "anything", payload: "secret" })).toBeNull();
});
});
describe("installDesktopCrashListeners", () => {
it("observes fatal, renderer and child failures through structural fields only", () => {
const appTarget = new EventEmitter();
const processTarget = new EventEmitter();
const records = [];
const mainContents = {};
const dispose = installDesktopCrashListeners({
appTarget,
processTarget,
record: (event) => records.push(event),
mainWebContents: () => mainContents,
});
const error = new TypeError("private message");
processTarget.emit("uncaughtExceptionMonitor", error, "unhandledRejection");
appTarget.emit(
"render-process-gone",
{},
mainContents,
{ reason: "crashed", exitCode: 7, url: "https://private.test" },
);
appTarget.emit(
"child-process-gone",
{},
{ type: "GPU", reason: "oom", exitCode: 34, name: "private-service" },
);
expect(records).toEqual([
{ kind: "main", error, origin: "unhandledRejection" },
{ kind: "renderer", surface: "main-window", reason: "crashed", exitCode: 7 },
{ kind: "child", type: "GPU", reason: "oom", exitCode: 34 },
]);
dispose();
expect(processTarget.listenerCount("uncaughtExceptionMonitor")).toBe(0);
expect(appTarget.listenerCount("render-process-gone")).toBe(0);
expect(appTarget.listenerCount("child-process-gone")).toBe(0);
});
it("ignores process exits after Electron has begun its normal shutdown", () => {
const appTarget = new EventEmitter();
const processTarget = new EventEmitter();
const records = [];
installDesktopCrashListeners({
appTarget,
processTarget,
record: (event) => records.push(event),
isShuttingDown: () => true,
});
appTarget.emit("render-process-gone", {}, {}, { reason: "killed", exitCode: 0 });
appTarget.emit("child-process-gone", {}, { type: "Utility", reason: "killed", exitCode: 0 });
expect(records).toEqual([]);
});
});
describe("decodeLogTail", () => {
it("preserves the full buffer when the read starts at the beginning", () => {
expect(decodeLogTail(Buffer.from("first\nsecond"), false)).toEqual({ tail: "first\nsecond", bytes: 12 });
});
it("drops a credential assignment split by a bounded tail read", () => {
const decoded = decodeLogTail(Buffer.from("RET=split-secret\nserver ready\n"), true);
expect(decoded).toEqual({ tail: "server ready\n", bytes: 13 });
expect(decoded.tail).not.toContain("split-secret");
});
it("returns an empty tail when a truncated buffer has no complete line", () => {
expect(decodeLogTail(Buffer.from("partial-secret"), true)).toEqual({ tail: "", bytes: 0 });
});
});
describe("readSafeLogTail", () => {
it("exports only a bounded complete-line updater tail from an isolated log fixture", () => {
const directory = mkdtempSync(join(tmpdir(), "openmausbot-updater-diagnostics-"));
try {
const log = join(directory, "updater.log");
const tail = "[info] update staged\n[error] https://updates.example.test/app.zip?jwt=private-query-token\n";
writeFileSync(log, `${"old-private-line\n".repeat(20_000)}${tail}`, { mode: 0o600 });
const result = readSafeLogTail(log);
expect(result.bytes).toBeLessThanOrEqual(256 * 1024);
expect(result.tail.endsWith(tail)).toBe(true);
const report = buildDiagnosticsReport({ updaterLogTail: result.tail });
expect(report).toContain("[info] update staged");
expect(report).not.toContain("private-query-token");
expect(report).not.toContain(directory);
expect(readSafeLogTail(join(directory, "absent-updater.log"))).toBeNull();
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
it("reads a bounded tail from a regular app-owned log", () => {
const directory = mkdtempSync(join(tmpdir(), "openmausbot-log-tail-"));
try {
const log = join(directory, "server.log");
writeFileSync(log, "partial-secret\nfirst\nsecond\n", { mode: 0o600 });
expect(readSafeLogTail(log, 17)).toEqual({ tail: "first\nsecond\n", bytes: 13 });
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
it.skipIf(process.platform === "win32")("rejects a log-path symlink", () => {
const directory = mkdtempSync(join(tmpdir(), "openmausbot-log-symlink-"));
try {
const privateFile = join(directory, "private.txt");
const log = join(directory, "desktop-crashes.log");
writeFileSync(privateFile, "private user content", { mode: 0o600 });
symlinkSync(privateFile, log);
expect(readSafeLogTail(log)).toBeNull();
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
});
describe("diagnosticsFileName", () => {
it("uses openmausbot-diagnostics-YYYYMMDD-HHmmss.txt", () => {
expect(diagnosticsFileName(new Date(2026, 7, 22, 16, 5, 9))).toBe(
"openmausbot-diagnostics-20260822-160509.txt",
);
});
});
it("appends a crash line, truncates a full log, and refuses a hard-linked one", () => {
const dir = mkdtempSync(join(tmpdir(), "omb-crash-"));
const file = join(dir, "logs", "desktop-crashes.log");
appendDesktopCrashRecord(file, 64, "first crash");
expect(readFileSync(file, "utf8")).toMatch(/^\[.+\] first crash\n$/);
appendDesktopCrashRecord(file, 10, "second crash");
expect(readFileSync(file, "utf8")).toMatch(/^\[.+\] second crash\n$/);
linkSync(file, join(dir, "other"));
appendDesktopCrashRecord(file, 1_000, "third crash");
expect(readFileSync(file, "utf8")).not.toContain("third crash");
rmSync(dir, { recursive: true, force: true });
});