fix(electrobun): address all 22 Codex review #2 findings

CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs

HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle

MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`

LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
This commit is contained in:
Hibryda 2026-03-22 02:30:09 +01:00
parent 8e756d3523
commit 1cd4558740
28 changed files with 1342 additions and 1164 deletions

View file

@ -0,0 +1,144 @@
/**
* Agent + Session persistence RPC handlers.
*/
import type { SidecarManager } from "../sidecar-manager.ts";
import type { SessionDb } from "../session-db.ts";
export function createAgentHandlers(
sidecarManager: SidecarManager,
sessionDb: SessionDb,
sendToWebview: { send: Record<string, (...args: unknown[]) => void> },
) {
return {
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
try {
const result = sidecarManager.startSession(
sessionId as string,
provider as string,
prompt as string,
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record<string, unknown>,
);
if (result.ok) {
sidecarManager.onMessage(sessionId as string, (sid: string, messages: unknown) => {
try {
(sendToWebview.send as Record<string, Function>)["agent.message"]({ sessionId: sid, messages });
} catch (err) {
console.error("[agent.message] forward error:", err);
}
});
sidecarManager.onStatus(sessionId as string, (sid: string, status: string, error?: string) => {
try {
(sendToWebview.send as Record<string, Function>)["agent.status"]({ sessionId: sid, status, error });
} catch (err) {
console.error("[agent.status] forward error:", err);
}
const sessions = sidecarManager.listSessions();
const session = sessions.find((s: Record<string, unknown>) => s.sessionId === sid);
if (session) {
try {
(sendToWebview.send as Record<string, Function>)["agent.cost"]({
sessionId: sid,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
});
} catch { /* ignore */ }
}
});
}
return result;
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[agent.start]", err);
return { ok: false, error };
}
},
"agent.stop": ({ sessionId }: { sessionId: string }) => {
try {
return sidecarManager.stopSession(sessionId);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[agent.stop]", err);
return { ok: false, error };
}
},
"agent.prompt": ({ sessionId, prompt }: { sessionId: string; prompt: string }) => {
try {
return sidecarManager.writePrompt(sessionId, prompt);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[agent.prompt]", err);
return { ok: false, error };
}
},
"agent.list": () => {
try {
return { sessions: sidecarManager.listSessions() };
} catch (err) {
console.error("[agent.list]", err);
return { sessions: [] };
}
},
// Session persistence
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }: Record<string, unknown>) => {
try {
sessionDb.saveSession({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt } as Record<string, unknown>);
return { ok: true };
} catch (err) {
console.error("[session.save]", err);
return { ok: false };
}
},
"session.load": ({ projectId }: { projectId: string }) => {
try {
return { session: sessionDb.loadSession(projectId) };
} catch (err) {
console.error("[session.load]", err);
return { session: null };
}
},
"session.list": ({ projectId }: { projectId: string }) => {
try {
return { sessions: sessionDb.listSessionsByProject(projectId) };
} catch (err) {
console.error("[session.list]", err);
return { sessions: [] };
}
},
"session.messages.save": ({ messages }: { messages: Array<Record<string, unknown>> }) => {
try {
sessionDb.saveMessages(messages.map((m) => ({
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
timestamp: m.timestamp, costUsd: (m.costUsd as number) ?? 0,
inputTokens: (m.inputTokens as number) ?? 0, outputTokens: (m.outputTokens as number) ?? 0,
})));
return { ok: true };
} catch (err) {
console.error("[session.messages.save]", err);
return { ok: false };
}
},
"session.messages.load": ({ sessionId }: { sessionId: string }) => {
try {
return { messages: sessionDb.loadMessages(sessionId) };
} catch (err) {
console.error("[session.messages.load]", err);
return { messages: [] };
}
},
};
}

View file

@ -0,0 +1,96 @@
/**
* btmsg + bttask RPC handlers.
*/
import type { BtmsgDb } from "../btmsg-db.ts";
import type { BttaskDb } from "../bttask-db.ts";
export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
return {
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record<string, unknown>) => {
try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; }
catch (err) { console.error("[btmsg.registerAgent]", err); return { ok: false }; }
},
"btmsg.getAgents": ({ groupId }: { groupId: string }) => {
try { return { agents: btmsgDb.getAgents(groupId) }; }
catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; }
},
"btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); return { ok: true, messageId }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.sendMessage]", err); return { ok: false, error }; }
},
"btmsg.listMessages": ({ agentId, otherId, limit }: { agentId: string; otherId: string; limit?: number }) => {
try { return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) }; }
catch (err) { console.error("[btmsg.listMessages]", err); return { messages: [] }; }
},
"btmsg.markRead": ({ agentId, messageIds }: { agentId: string; messageIds: string[] }) => {
try { btmsgDb.markRead(agentId, messageIds); return { ok: true }; }
catch (err) { console.error("[btmsg.markRead]", err); return { ok: false }; }
},
"btmsg.listChannels": ({ groupId }: { groupId: string }) => {
try { return { channels: btmsgDb.listChannels(groupId) }; }
catch (err) { console.error("[btmsg.listChannels]", err); return { channels: [] }; }
},
"btmsg.createChannel": ({ name, groupId, createdBy }: { name: string; groupId: string; createdBy: string }) => {
try { const channelId = btmsgDb.createChannel(name, groupId, createdBy); return { ok: true, channelId }; }
catch (err) { console.error("[btmsg.createChannel]", err); return { ok: false }; }
},
"btmsg.getChannelMessages": ({ channelId, limit }: { channelId: string; limit?: number }) => {
try { return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) }; }
catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; }
},
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); return { ok: true, messageId }; }
catch (err) { console.error("[btmsg.sendChannelMessage]", err); return { ok: false }; }
},
"btmsg.heartbeat": ({ agentId }: { agentId: string }) => {
try { btmsgDb.heartbeat(agentId); return { ok: true }; }
catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; }
},
"btmsg.getDeadLetters": ({ limit }: { limit?: number }) => {
try { return { letters: btmsgDb.getDeadLetters(limit ?? 50) }; }
catch (err) { console.error("[btmsg.getDeadLetters]", err); return { letters: [] }; }
},
"btmsg.logAudit": ({ agentId, eventType, detail }: { agentId: string; eventType: string; detail: string }) => {
try { btmsgDb.logAudit(agentId, eventType, detail); return { ok: true }; }
catch (err) { console.error("[btmsg.logAudit]", err); return { ok: false }; }
},
"btmsg.getAuditLog": ({ limit }: { limit?: number }) => {
try { return { entries: btmsgDb.getAuditLog(limit ?? 100) }; }
catch (err) { console.error("[btmsg.getAuditLog]", err); return { entries: [] }; }
},
};
}
export function createBttaskHandlers(bttaskDb: BttaskDb) {
return {
"bttask.listTasks": ({ groupId }: { groupId: string }) => {
try { return { tasks: bttaskDb.listTasks(groupId) }; }
catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; }
},
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record<string, unknown>) => {
try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); return { ok: true, taskId }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.createTask]", err); return { ok: false, error }; }
},
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }: { taskId: string; status: string; expectedVersion: number }) => {
try { const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); return { ok: true, newVersion }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.updateTaskStatus]", err); return { ok: false, error }; }
},
"bttask.deleteTask": ({ taskId }: { taskId: string }) => {
try { bttaskDb.deleteTask(taskId); return { ok: true }; }
catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; }
},
"bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => {
try { const commentId = bttaskDb.addComment(taskId, agentId, content); return { ok: true, commentId }; }
catch (err) { console.error("[bttask.addComment]", err); return { ok: false }; }
},
"bttask.listComments": ({ taskId }: { taskId: string }) => {
try { return { comments: bttaskDb.listComments(taskId) }; }
catch (err) { console.error("[bttask.listComments]", err); return { comments: [] }; }
},
"bttask.reviewQueueCount": ({ groupId }: { groupId: string }) => {
try { return { count: bttaskDb.reviewQueueCount(groupId) }; }
catch (err) { console.error("[bttask.reviewQueueCount]", err); return { count: 0 }; }
},
};
}

View file

@ -0,0 +1,95 @@
/**
* File I/O RPC handlers list, read, write with path traversal protection.
*/
import path from "path";
import fs from "fs";
import { guardPath } from "./path-guard.ts";
export function createFilesHandlers() {
return {
"files.list": async ({ path: dirPath }: { path: string }) => {
const guard = guardPath(dirPath);
if (!guard.valid) {
console.error(`[files.list] blocked: ${guard.error}`);
return { entries: [], error: guard.error };
}
try {
const dirents = fs.readdirSync(guard.resolved, { withFileTypes: true });
const entries = dirents
.filter((d) => !d.name.startsWith("."))
.map((d) => {
let size = 0;
if (d.isFile()) {
try {
size = fs.statSync(path.join(guard.resolved, d.name)).size;
} catch { /* ignore stat errors */ }
}
return {
name: d.name,
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
size,
};
})
.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.list]", error);
return { entries: [], error };
}
},
"files.read": async ({ path: filePath }: { path: string }) => {
const guard = guardPath(filePath);
if (!guard.valid) {
console.error(`[files.read] blocked: ${guard.error}`);
return { encoding: "utf8" as const, size: 0, error: guard.error };
}
try {
const stat = fs.statSync(guard.resolved);
const MAX_SIZE = 10 * 1024 * 1024;
if (stat.size > MAX_SIZE) {
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
}
const buf = Buffer.alloc(Math.min(8192, stat.size));
const fd = fs.openSync(guard.resolved, "r");
fs.readSync(fd, buf, 0, buf.length, 0);
fs.closeSync(fd);
const isBinary = buf.includes(0);
if (isBinary) {
const content = fs.readFileSync(guard.resolved).toString("base64");
return { content, encoding: "base64" as const, size: stat.size };
}
const content = fs.readFileSync(guard.resolved, "utf8");
return { content, encoding: "utf8" as const, size: stat.size };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.read]", error);
return { encoding: "utf8" as const, size: 0, error };
}
},
"files.write": async ({ path: filePath, content }: { path: string; content: string }) => {
const guard = guardPath(filePath);
if (!guard.valid) {
console.error(`[files.write] blocked: ${guard.error}`);
return { ok: false, error: guard.error };
}
try {
fs.writeFileSync(guard.resolved, content, "utf8");
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.write]", error);
return { ok: false, error };
}
},
};
}

View file

@ -0,0 +1,58 @@
/**
* Path traversal guard validates that resolved paths stay within allowed boundaries.
*
* Used by file I/O handlers to prevent path traversal attacks (CWE-22).
*/
import path from "path";
import { settingsDb } from "../settings-db.ts";
import { homedir } from "os";
import { join } from "path";
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
/** Get all configured project CWDs from settings DB. */
function getAllowedRoots(): string[] {
const roots: string[] = [PLUGINS_DIR];
try {
const projects = settingsDb.listProjects();
for (const p of projects) {
if (typeof p.cwd === "string" && p.cwd) {
roots.push(path.resolve(p.cwd));
}
}
} catch {
// If settings DB is unavailable, still allow plugins dir
}
return roots;
}
/**
* Validate that a file path is within an allowed boundary.
* Returns the resolved path if valid, or null if outside boundaries.
*/
export function validatePath(filePath: string): string | null {
const resolved = path.resolve(filePath);
const roots = getAllowedRoots();
for (const root of roots) {
const resolvedRoot = path.resolve(root);
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
return resolved;
}
}
return null;
}
/**
* Check path and return error response if invalid.
* Returns { valid: true, resolved: string } or { valid: false, error: string }.
*/
export function guardPath(filePath: string): { valid: true; resolved: string } | { valid: false; error: string } {
const resolved = validatePath(filePath);
if (resolved === null) {
return { valid: false, error: `Access denied: path outside allowed project directories` };
}
return { valid: true, resolved };
}

View file

@ -0,0 +1,71 @@
/**
* Plugin discovery + file reading RPC handlers.
*/
import fs from "fs";
import path from "path";
import { join } from "path";
import { homedir } from "os";
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
export function createPluginHandlers() {
return {
"plugin.discover": () => {
try {
const plugins: Array<{
id: string; name: string; version: string;
description: string; main: string; permissions: string[];
}> = [];
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
if (!fs.existsSync(manifestPath)) continue;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
const manifest = JSON.parse(raw);
plugins.push({
id: manifest.id ?? entry.name,
name: manifest.name ?? entry.name,
version: manifest.version ?? "0.0.0",
description: manifest.description ?? "",
main: manifest.main ?? "index.js",
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
});
} catch (parseErr) {
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
}
}
return { plugins };
} catch (err) {
console.error("[plugin.discover]", err);
return { plugins: [] };
}
},
"plugin.readFile": ({ pluginId, filePath }: { pluginId: string; filePath: string }) => {
try {
const pluginDir = join(PLUGINS_DIR, pluginId);
const resolved = path.resolve(pluginDir, filePath);
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
return { ok: false, content: "", error: "Path traversal blocked" };
}
if (!fs.existsSync(resolved)) {
return { ok: false, content: "", error: "File not found" };
}
const content = fs.readFileSync(resolved, "utf-8");
return { ok: true, content };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[plugin.readFile]", err);
return { ok: false, content: "", error };
}
},
};
}

View file

@ -0,0 +1,69 @@
/**
* PTY RPC handlers create, write, resize, unsubscribe, close sessions.
*/
import type { PtyClient } from "../pty-client.ts";
export function createPtyHandlers(ptyClient: PtyClient) {
return {
"pty.create": async ({ sessionId, cols, rows, cwd, shell, args }: {
sessionId: string; cols: number; rows: number; cwd?: string;
shell?: string; args?: string[];
}) => {
if (!ptyClient.isConnected) {
return { ok: false, error: "PTY daemon not connected" };
}
try {
ptyClient.createSession({ id: sessionId, cols, rows, cwd, shell, args });
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error(`[pty.create] ${sessionId}: ${error}`);
return { ok: false, error };
}
},
"pty.write": ({ sessionId, data }: { sessionId: string; data: string }) => {
if (!ptyClient.isConnected) {
console.error(`[pty.write] ${sessionId}: daemon not connected`);
return { ok: false };
}
try {
ptyClient.writeInput(sessionId, data);
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error(`[pty.write] ${sessionId}: ${error}`);
return { ok: false };
}
},
"pty.resize": ({ sessionId, cols, rows }: { sessionId: string; cols: number; rows: number }) => {
if (!ptyClient.isConnected) return { ok: true };
try {
ptyClient.resize(sessionId, cols, rows);
} catch (err) {
console.error(`[pty.resize] ${sessionId}:`, err);
}
return { ok: true };
},
"pty.unsubscribe": ({ sessionId }: { sessionId: string }) => {
try {
ptyClient.unsubscribe(sessionId);
} catch (err) {
console.error(`[pty.unsubscribe] ${sessionId}:`, err);
}
return { ok: true };
},
"pty.close": ({ sessionId }: { sessionId: string }) => {
try {
ptyClient.closeSession(sessionId);
} catch (err) {
console.error(`[pty.close] ${sessionId}:`, err);
}
return { ok: true };
},
};
}

View file

@ -0,0 +1,65 @@
/**
* Remote machine (relay) RPC handlers.
*/
import type { RelayClient } from "../relay-client.ts";
export function createRemoteHandlers(relayClient: RelayClient) {
return {
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
try {
const machineId = await relayClient.connect(url, token, label);
return { ok: true, machineId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[remote.connect]", err);
return { ok: false, error };
}
},
"remote.disconnect": ({ machineId }: { machineId: string }) => {
try {
relayClient.disconnect(machineId);
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[remote.disconnect]", err);
return { ok: false, error };
}
},
"remote.list": () => {
try {
return { machines: relayClient.listMachines() };
} catch (err) {
console.error("[remote.list]", err);
return { machines: [] };
}
},
"remote.send": ({ machineId, command, payload }: { machineId: string; command: string; payload: Record<string, unknown> }) => {
try {
relayClient.sendCommand(machineId, command, payload);
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[remote.send]", err);
return { ok: false, error };
}
},
"remote.status": ({ machineId }: { machineId: string }) => {
try {
const info = relayClient.getStatus(machineId);
if (!info) {
return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" };
}
return { status: info.status, latencyMs: info.latencyMs };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[remote.status]", err);
return { status: "error" as const, latencyMs: null, error };
}
},
};
}

View file

@ -0,0 +1,41 @@
/**
* Search RPC handlers FTS5 full-text search.
* Fix #13: Returns typed error for invalid queries.
*/
import type { SearchDb } from "../search-db.ts";
export function createSearchHandlers(searchDb: SearchDb) {
return {
"search.query": ({ query, limit }: { query: string; limit?: number }) => {
try {
const results = searchDb.searchAll(query, limit ?? 20);
return { results };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[search.query]", err);
return { results: [], error };
}
},
"search.indexMessage": ({ sessionId, role, content }: { sessionId: string; role: string; content: string }) => {
try {
searchDb.indexMessage(sessionId, role, content);
return { ok: true };
} catch (err) {
console.error("[search.indexMessage]", err);
return { ok: false };
}
},
"search.rebuild": () => {
try {
searchDb.rebuildIndex();
return { ok: true };
} catch (err) {
console.error("[search.rebuild]", err);
return { ok: false };
}
},
};
}

View file

@ -0,0 +1,131 @@
/**
* Settings + Groups + Themes RPC handlers.
*/
import type { SettingsDb } from "../settings-db.ts";
export function createSettingsHandlers(settingsDb: SettingsDb) {
return {
"settings.get": ({ key }: { key: string }) => {
try {
return { value: settingsDb.getSetting(key) };
} catch (err) {
console.error("[settings.get]", err);
return { value: null };
}
},
"settings.set": ({ key, value }: { key: string; value: string }) => {
try {
settingsDb.setSetting(key, value);
return { ok: true };
} catch (err) {
console.error("[settings.set]", err);
return { ok: false };
}
},
"settings.getAll": () => {
try {
return { settings: settingsDb.getAll() };
} catch (err) {
console.error("[settings.getAll]", err);
return { settings: {} };
}
},
"settings.getProjects": () => {
try {
const projects = settingsDb.listProjects().map((p: Record<string, unknown>) => ({
id: p.id,
config: JSON.stringify(p),
}));
return { projects };
} catch (err) {
console.error("[settings.getProjects]", err);
return { projects: [] };
}
},
"settings.setProject": ({ id, config }: { id: string; config: string }) => {
try {
const parsed = JSON.parse(config);
settingsDb.setProject(id, { id, ...parsed });
return { ok: true };
} catch (err) {
console.error("[settings.setProject]", err);
return { ok: false };
}
},
"settings.deleteProject": ({ id }: { id: string }) => {
try {
settingsDb.deleteProject(id);
return { ok: true };
} catch (err) {
console.error("[settings.deleteProject]", err);
return { ok: false };
}
},
// Groups
"groups.list": () => {
try {
return { groups: settingsDb.listGroups() };
} catch (err) {
console.error("[groups.list]", err);
return { groups: [] };
}
},
"groups.create": ({ id, name, icon, position }: { id: string; name: string; icon: string; position: number }) => {
try {
settingsDb.createGroup(id, name, icon, position);
return { ok: true };
} catch (err) {
console.error("[groups.create]", err);
return { ok: false };
}
},
"groups.delete": ({ id }: { id: string }) => {
try {
settingsDb.deleteGroup(id);
return { ok: true };
} catch (err) {
console.error("[groups.delete]", err);
return { ok: false };
}
},
// Custom themes
"themes.getCustom": () => {
try {
return { themes: settingsDb.getCustomThemes() };
} catch (err) {
console.error("[themes.getCustom]", err);
return { themes: [] };
}
},
"themes.saveCustom": ({ id, name, palette }: { id: string; name: string; palette: Record<string, string> }) => {
try {
settingsDb.saveCustomTheme(id, name, palette);
return { ok: true };
} catch (err) {
console.error("[themes.saveCustom]", err);
return { ok: false };
}
},
"themes.deleteCustom": ({ id }: { id: string }) => {
try {
settingsDb.deleteCustomTheme(id);
return { ok: true };
} catch (err) {
console.error("[themes.deleteCustom]", err);
return { ok: false };
}
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -58,7 +58,7 @@ export class PtyClient extends EventEmitter {
this.tokenPath = join(dir, "ptyd.token");
}
/** Connect to daemon and authenticate. */
/** Connect to daemon and authenticate. Fix #10: 5-second timeout. */
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
let token: string;
@ -69,6 +69,16 @@ export class PtyClient extends EventEmitter {
return;
}
let settled = false;
// Fix #10: 5-second timeout on connect
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
this.socket?.destroy();
reject(new Error("Connection timeout (5s). Is agor-ptyd running?"));
}, 5_000);
this.socket = connect(this.socketPath);
this.socket.on("connect", () => {
@ -84,6 +94,9 @@ export class PtyClient extends EventEmitter {
try {
const msg = JSON.parse(line) as DaemonEvent;
if (!this.authenticated && msg.type === "auth_result") {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (msg.ok) {
this.authenticated = true;
resolve();
@ -101,21 +114,32 @@ export class PtyClient extends EventEmitter {
});
this.socket.on("error", (err) => {
if (!this.authenticated) reject(err);
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(err);
}
this.emit("error", err);
});
this.socket.on("close", () => {
this.authenticated = false;
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(new Error("Connection closed before auth"));
}
this.emit("close");
});
});
}
/** Create a new PTY session. */
/** Create a new PTY session. Fix #3: accepts shell + args for direct command spawning. */
createSession(opts: {
id: string;
shell?: string;
/** Arguments to pass to shell (used for SSH direct spawn). */
args?: string[];
cwd?: string;
env?: Record<string, string>;
cols?: number;
@ -125,6 +149,7 @@ export class PtyClient extends EventEmitter {
type: "create_session",
id: opts.id,
shell: opts.shell ?? null,
args: opts.args ?? null,
cwd: opts.cwd ?? null,
env: opts.env ?? null,
cols: opts.cols ?? 80,