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
7 changes: 6 additions & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ body:
id: logs
attributes:
label: Logs/screenshots
description: Paste relevant logs or attach screenshots
description: Paste relevant logs or attach screenshots. You can generate a sanitized bundle in-app from Settings → General → Copy Debug Info.
- type: textarea
id: debug_info
attributes:
label: Debug info bundle
description: Paste the copied debug info JSON block from Settings → General → Copy Debug Info.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Use the bug report template and include:
- Repro steps
- Expected vs actual behavior
- Logs and screenshots where possible
- Debug bundle from `Settings → General → Copy Debug Info` (paste into issue and optionally attach the generated `debug-info-*.json` file path shown in-app)

## Recognition

Expand Down
217 changes: 217 additions & 0 deletions electron/src/handlers/debug.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { app, ipcMain } from "electron";
import fs from "fs";
import os from "os";
import path from "path";
import crypto from "crypto";
import { getDataDir } from "../lib/data-dir";
import { loadMcpServers } from "../lib/mcp-store";
import { getCurrentLogFile, getLogsDir, log } from "../lib/logger";
import { sessions } from "./oagent-sessions.handler";
import { oapSessions } from "./oap-sessions.handler";
import { terminals } from "./terminal.handler";

interface ProjectRecord {
id: string;
name: string;
path: string;
}

interface DebugIssuePayload {
generatedAt: string;
app: Record<string, unknown>;
runtime: Record<string, unknown>;
projects: unknown[];
mcp: unknown;
logs: unknown;
}

function shortHash(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
}

function sanitizePath(value: string): { name: string; hash: string } {
return {
name: path.basename(value),
hash: shortHash(value),
};
}

function readProjects(): ProjectRecord[] {
const filePath = path.join(getDataDir(), "projects.json");
if (!fs.existsSync(filePath)) return [];
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ProjectRecord[];
} catch {
return [];
}
}

function sanitizeUrl(raw?: string): string | undefined {
if (!raw) return undefined;
try {
const parsed = new URL(raw);
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
return "<invalid-url>";
}
}

function redactSensitive(text: string): string {
const home = os.homedir();
const escapedHome = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text
.replace(new RegExp(escapedHome, "g"), "<home>")
.replace(/[A-Za-z]:\\[^ \n\r\t"']+/g, "<windows-path>")
.replace(/(token|key|secret|password)=([^&\s]+)/gi, "$1=[REDACTED]")
.replace(/\b(gh[opus]_[A-Za-z0-9]{20,})\b/g, "[REDACTED]")
.replace(/\b(sk-or-v1-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{8,})\b/g, "[REDACTED]")
.replace(/\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/gi, "Bearer [REDACTED]");
}

function parseRecentLogDetails() {
const logDir = getLogsDir();
const fileEntries = fs.existsSync(logDir)
? fs.readdirSync(logDir)
.filter((name) => name.endsWith(".log"))
.map((name) => {
const fullPath = path.join(logDir, name);
const stat = fs.statSync(fullPath);
return { name, fullPath, stat };
})
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
: [];

const recentFiles = fileEntries.slice(0, 5).map((entry) => ({
file: entry.name,
bytes: entry.stat.size,
modifiedAt: new Date(entry.stat.mtimeMs).toISOString(),
}));

const newest = fileEntries[0];
const labelCounts: Record<string, number> = {};
const recentErrors: Array<{ ts: string; label: string; message: string }> = [];

if (newest) {
const lines = fs.readFileSync(newest.fullPath, "utf-8").split("\n").filter(Boolean);
const tail = lines.slice(-600);
for (const line of tail) {
const match = line.match(/^\[(?<ts>[^\]]+)\]\s\[(?<label>[^\]]+)\]\s(?<msg>.*)$/);
if (!match?.groups) continue;
const ts = match.groups.ts;
const label = match.groups.label;
const msg = match.groups.msg;
labelCounts[label] = (labelCounts[label] ?? 0) + 1;
if (label.includes("ERR") || label.includes("WARN")) {
recentErrors.push({
ts,
label,
message: redactSensitive(msg).slice(0, 240),
});
}
}
}

return {
currentLogFile: path.basename(getCurrentLogFile()),
recentFiles,
labelCounts,
recentErrors: recentErrors.slice(-25),
};
}

function buildPayload(): DebugIssuePayload {
const generatedAt = new Date().toISOString();
const dataDir = getDataDir();
const projects = readProjects();
const logSummary = parseRecentLogDetails();

const projectSummaries = projects.map((project) => {
const mcpServers = loadMcpServers(project.id);
const sessionDir = path.join(dataDir, "sessions", project.id);
const persistedSessions = fs.existsSync(sessionDir)
? fs.readdirSync(sessionDir).filter((name) => name.endsWith(".json")).length
: 0;
return {
id: project.id,
name: project.name,
path: sanitizePath(project.path),
persistedSessions,
mcpServers: mcpServers.map((server) => ({
name: server.name,
transport: server.transport,
hasCommand: Boolean(server.command),
argsCount: server.args?.length ?? 0,
hasEnv: Boolean(server.env && Object.keys(server.env).length > 0),
envKeyCount: Object.keys(server.env ?? {}).length,
hasHeaders: Boolean(server.headers && Object.keys(server.headers).length > 0),
headerKeyCount: Object.keys(server.headers ?? {}).length,
endpoint: sanitizeUrl(server.url),
})),
};
});

const oauthDir = path.join(dataDir, "mcp-oauth");
const oauthFiles = fs.existsSync(oauthDir)
? fs.readdirSync(oauthDir).filter((name) => name.endsWith(".json"))
: [];

return {
generatedAt,
app: {
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: app.getLocale(),
},
runtime: {
activeOAgentSessions: sessions.size,
activeOapSessions: oapSessions.size,
activeTerminals: terminals.size,
projectCount: projects.length,
},
projects: projectSummaries,
mcp: {
oauthTokenFiles: oauthFiles.length,
oauthServerIds: oauthFiles.map((name) => path.basename(name, ".json")),
},
logs: logSummary,
};
}

function buildIssueMarkdown(payload: DebugIssuePayload): string {
return [
"### OAgent Debug Info",
"",
"Paste this section into your GitHub issue:",
"",
"```json",
JSON.stringify(payload, null, 2),
"```",
].join("\n");
}

export function register(): void {
ipcMain.handle("debug:collect", () => {
try {
const payload = buildPayload();
const outputDir = path.join(getDataDir(), "debug-reports");
fs.mkdirSync(outputDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const outputFile = path.join(outputDir, `debug-info-${timestamp}.json`);
fs.writeFileSync(outputFile, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
return {
report: buildIssueMarkdown(payload),
filePath: outputFile,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log("DEBUG_COLLECT_ERR", message);
return { error: message };
}
});
}
8 changes: 8 additions & 0 deletions electron/src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ fs.mkdirSync(logsDir, { recursive: true });
const logFile = path.join(logsDir, `main-${Date.now()}.log`);
const logStream = fs.createWriteStream(logFile, { flags: "a" });

export function getLogsDir(): string {
return logsDir;
}

export function getCurrentLogFile(): string {
return logFile;
}

export function log(label: string, data: unknown): void {
const ts = new Date().toISOString();
const line = typeof data === "string" ? data : JSON.stringify(data, null, 2);
Expand Down
2 changes: 2 additions & 0 deletions electron/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as gitHandler from "./handlers/git.handler";
import * as agentRegistryHandler from "./handlers/oagent-registry.handler";
import * as oapSessionsHandler from "./handlers/oap-sessions.handler";
import * as mcpHandler from "./handlers/mcp.handler";
import * as debugHandler from "./handlers/debug.handler";
import { ipcMain } from "electron";

// --- Liquid Glass command-line switches (must be set before app.whenReady()) ---
Expand Down Expand Up @@ -97,6 +98,7 @@ gitHandler.register();
agentRegistryHandler.register();
oapSessionsHandler.register(getMainWindow);
mcpHandler.register();
debugHandler.register();

// --- DevTools in separate window via remote debugging ---
let devToolsWindow: BrowserWindow | null = null;
Expand Down
3 changes: 3 additions & 0 deletions electron/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ const clientCoreApi = {
authStatus: (serverName: string) => ipcRenderer.invoke("mcp:auth-status", serverName),
probe: (servers: unknown[]) => ipcRenderer.invoke("mcp:probe", servers),
},
debug: {
collect: () => ipcRenderer.invoke("debug:collect"),
},
agents: {
list: () => ipcRenderer.invoke("agents:list"),
save: (agent: unknown) => ipcRenderer.invoke("agents:save", agent),
Expand Down
61 changes: 60 additions & 1 deletion src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { memo } from "react";
import { memo, useCallback, useState } from "react";
import {
Settings as SettingsIcon,
Key,
Cpu,
ChevronRight,
RefreshCcw,
Bug,
Copy,
} from "lucide-react";
import {
Dialog,
Expand All @@ -28,6 +30,30 @@ export const SettingsDialog = memo(function SettingsDialog({
onOpenChange,
settings,
}: SettingsDialogProps) {
const [debugInfoState, setDebugInfoState] = useState<"idle" | "copying" | "copied" | "error">("idle");
const [debugInfoMessage, setDebugInfoMessage] = useState("");

const handleCopyDebugInfo = useCallback(async () => {
setDebugInfoState("copying");
setDebugInfoMessage("");
try {
const result = await window.clientCore.debug.collect();
if (!result.report) {
throw new Error(result.error ?? "Unable to collect debug info.");
}
await navigator.clipboard.writeText(result.report);
setDebugInfoState("copied");
setDebugInfoMessage(
result.filePath
? `Copied to clipboard. Local file: ${result.filePath}`
: "Copied to clipboard.",
);
} catch (err) {
setDebugInfoState("error");
setDebugInfoMessage(err instanceof Error ? err.message : "Unable to copy debug info.");
}
}, []);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden border-border/40 bg-background/95 backdrop-blur-xl animate-scale-in">
Expand Down Expand Up @@ -189,6 +215,39 @@ export const SettingsDialog = memo(function SettingsDialog({
<ChevronRight className="h-3 w-3" />
</Button>
</div>

<div className="p-4 rounded-lg border border-border/40 bg-muted/10 space-y-3">
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
<Bug className="h-3.5 w-3.5" />
Bug Report Debug Info
</div>
<div className="text-xs text-muted-foreground">
Collect sanitized diagnostics (no workspace file contents or secret values) and copy it for GitHub issues.
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Button
size="sm"
variant="outline"
className="gap-2"
onClick={handleCopyDebugInfo}
disabled={debugInfoState === "copying"}
>
<Copy className="h-3 w-3" />
{debugInfoState === "copying" ? "Collecting..." : "Copy Debug Info"}
</Button>
{debugInfoMessage ? (
<span
className={`text-[10px] text-right ${
debugInfoState === "error" ? "text-destructive" : "text-muted-foreground"
}`}
>
{debugInfoMessage}
</span>
) : null}
</div>
</div>
</TabsContent>

<TabsContent
Expand Down
3 changes: 3 additions & 0 deletions src/types/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ declare global {
authStatus: (serverName: string) => Promise<{ hasToken: boolean; expiresAt?: number }>;
probe: (servers: McpServerConfig[]) => Promise<Array<{ name: string; status: "connected" | "needs-auth" | "failed"; error?: string }>>;
};
debug: {
collect: () => Promise<{ report?: string; filePath?: string; error?: string }>;
};
agents: {
list: () => Promise<AgentDefinition[]>;
save: (agent: AgentDefinition) => Promise<{ ok?: boolean; error?: string }>;
Expand Down
Loading