Add app diagnostic report (#1728)
* feat(diagnostics): add app diagnostic report * fix(diagnostics): guard app diagnostic runs * fix(diagnostics): include websocket runtime metrics * fix(diagnostics): type websocket metric snapshot * fix(diagnostics): include daemon shell env * fix(loop): make stop cancellation test deterministic
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: "srv-secret",
|
||||
label: "Secret host",
|
||||
lifecycle: {},
|
||||
preferredConnectionId: "direct:secret.example.test:6767",
|
||||
createdAt: "2026-06-25T00:00:00.000Z",
|
||||
updatedAt: "2026-06-25T00:00:00.000Z",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:secret.example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "secret.example.test:6767",
|
||||
useTls: true,
|
||||
password: "tcp-password",
|
||||
},
|
||||
{
|
||||
id: "relay:relay.secret.test:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.secret.test:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "daemon-public-key-secret",
|
||||
},
|
||||
{
|
||||
id: "socket:/tmp/paseo-secret.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo-secret.sock",
|
||||
},
|
||||
{
|
||||
id: "pipe:\\\\.\\pipe\\paseo-secret",
|
||||
type: "directPipe",
|
||||
path: "\\\\.\\pipe\\paseo-secret",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
};
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
expect(report).toContain("local pipe");
|
||||
expect(report).not.toContain("secret.example.test");
|
||||
expect(report).not.toContain("relay.secret.test");
|
||||
expect(report).not.toContain("daemon-public-key-secret");
|
||||
expect(report).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
[
|
||||
"secret.example.test:6767",
|
||||
"relay.secret.test:443",
|
||||
"daemon-public-key-secret",
|
||||
"/tmp/paseo-secret.sock",
|
||||
"\\\\.\\pipe\\paseo-secret",
|
||||
"password=tcp-password",
|
||||
"paseo://pairing-secret",
|
||||
].join("\n"),
|
||||
[host],
|
||||
);
|
||||
|
||||
expect(redacted).not.toContain("secret.example.test");
|
||||
expect(redacted).not.toContain("relay.secret.test");
|
||||
expect(redacted).not.toContain("daemon-public-key-secret");
|
||||
expect(redacted).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(redacted).not.toContain("\\\\.\\pipe\\paseo-secret");
|
||||
expect(redacted).not.toContain("tcp-password");
|
||||
expect(redacted).not.toContain("pairing-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
|
||||
interface DiagnosticEntry {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
|
||||
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
}
|
||||
|
||||
export function formatAppDiagnosticHeader(input: {
|
||||
appVersion: string | null;
|
||||
platform: string;
|
||||
isDesktopApp: boolean;
|
||||
hostCount: number;
|
||||
}): string {
|
||||
return formatDiagnosticSection("Paseo app diagnostics", [
|
||||
{ label: "Collected at", value: new Date().toISOString() },
|
||||
{ label: "App version", value: input.appVersion ?? "unknown" },
|
||||
{ label: "Platform", value: input.platform },
|
||||
{ label: "Desktop app", value: String(input.isDesktopApp) },
|
||||
{ label: "Saved hosts", value: String(input.hostCount) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function formatHostRuntimeSection(input: {
|
||||
host: HostProfile;
|
||||
snapshot: HostRuntimeSnapshot | null;
|
||||
}): string {
|
||||
const { host, snapshot } = input;
|
||||
const entries: DiagnosticEntry[] = [
|
||||
{ label: "Server ID", value: host.serverId },
|
||||
{ label: "Status", value: snapshot?.connectionStatus ?? "not started" },
|
||||
{
|
||||
label: "Active connection",
|
||||
value: snapshot?.activeConnection
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
value: snapshot?.agentDirectoryStatus ?? "unknown",
|
||||
},
|
||||
];
|
||||
|
||||
const connectionRows = host.connections.map((connection, index) => {
|
||||
const probe = snapshot?.probeByConnectionId.get(connection.id) ?? null;
|
||||
const isActive = snapshot?.activeConnectionId === connection.id;
|
||||
return {
|
||||
label: `Connection ${index + 1}`,
|
||||
value: [
|
||||
describeConnectionKind(connection.type),
|
||||
isActive ? "active" : "inactive",
|
||||
probe ? `probe=${probe.status}` : "probe=unknown",
|
||||
probe?.status === "available" ? `latency=${Math.round(probe.latencyMs)}ms` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
};
|
||||
});
|
||||
|
||||
return formatDiagnosticSection(`Host: ${host.label}`, [...entries, ...connectionRows]);
|
||||
}
|
||||
|
||||
export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | null): string {
|
||||
if (!serverInfo) {
|
||||
return formatDiagnosticSection("Server info", [{ label: "Status", value: "not received" }]);
|
||||
}
|
||||
|
||||
const features = serverInfo.features ? Object.keys(serverInfo.features).sort() : [];
|
||||
return formatDiagnosticSection("Server info", [
|
||||
{ label: "Server ID", value: serverInfo.serverId },
|
||||
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
|
||||
{ label: "Version", value: serverInfo.version ?? "unknown" },
|
||||
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
|
||||
]);
|
||||
}
|
||||
|
||||
export function describeConnectionKind(type: HostConnection["type"] | string): string {
|
||||
switch (type) {
|
||||
case "directTcp":
|
||||
return "direct TCP";
|
||||
case "directSocket":
|
||||
return "local socket";
|
||||
case "directPipe":
|
||||
return "local pipe";
|
||||
case "relay":
|
||||
return "relay";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): string {
|
||||
let redacted = report;
|
||||
for (const value of collectSensitiveHostValues(hosts)) {
|
||||
redacted = redacted.split(value).join("[redacted]");
|
||||
}
|
||||
return redacted
|
||||
.replace(/paseo:\/\/\S+/gi, "paseo://[redacted]")
|
||||
.replace(
|
||||
/([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(
|
||||
/((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
}
|
||||
|
||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
for (const connection of host.connections) {
|
||||
values.add(connection.id);
|
||||
if (connection.type === "directTcp") {
|
||||
values.add(connection.endpoint);
|
||||
if (connection.password) values.add(connection.password);
|
||||
} else if (connection.type === "relay") {
|
||||
values.add(connection.relayEndpoint);
|
||||
values.add(connection.daemonPublicKeyB64);
|
||||
} else if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
values.add(connection.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...values].filter((value) => value.trim().length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user