fix(electrobun): complete all 16 Codex #3 findings

CRITICAL:
- Message persistence race: snapshot batchEnd before async save
- Double-start guard: startingProjects Set prevents concurrent launches
- Symlink path traversal: fs.realpathSync() in path-guard.ts
- Relay false success: connect() returns { ok, machineId, error }

HIGH:
- Session restore skips if active session exists
- Remote remove: new RPC, cleans backend map
- Task board poll token: stale responses discarded after drag-drop
- Health concurrent tools: toolsInFlight counter (was boolean)
- bttask transactions: delete wraps comments+task, addComment validates
- PTY buffer cleared on reconnect
- PTY large paste: chunked String.fromCharCode (8KB chunks)
- Sidecar max line: 10MB limit prevents unbounded memory
- btmsg authorization: group validation, channel membership checks

MEDIUM:
- Session retention: max 5 per project, purgeSession/untrackProject
- Relay IPv6: URL parser replaces string split
- PTY schema: fixed misleading base64 comment
This commit is contained in:
Hibryda 2026-03-22 02:52:04 +01:00
parent c145e37316
commit 0f75cb8e32
12 changed files with 190 additions and 42 deletions

View file

@ -63,8 +63,12 @@ export class RelayClient {
this.statusListeners.push(cb);
}
/** Connect to an agor-relay instance. Returns a machine ID. */
async connect(url: string, token: string, label?: string): Promise<string> {
/**
* Connect to an agor-relay instance.
* Fix #4 (Codex audit): Returns { ok, machineId, error } instead of always
* returning machineId even on failure.
*/
async connect(url: string, token: string, label?: string): Promise<{ ok: boolean; machineId?: string; error?: string }> {
const machineId = randomUUID();
const machine: MachineConnection = {
machineId,
@ -84,14 +88,14 @@ export class RelayClient {
try {
await this.openWebSocket(machine);
return { ok: true, machineId };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
machine.status = "error";
this.emitStatus(machineId, "error", msg);
this.scheduleReconnect(machine);
return { ok: false, machineId, error: msg };
}
return machineId;
}
/** Disconnect from a relay and stop reconnection attempts. */
@ -299,16 +303,24 @@ export class RelayClient {
machine.reconnectTimer = setTimeout(attempt, delay);
}
/** TCP-only probe to check if the relay host is reachable. */
private tcpProbe(url: string): Promise<boolean> {
/**
* TCP-only probe to check if the relay host is reachable.
* Fix #15 (Codex audit): Uses URL() to correctly parse IPv6, ports, etc.
*/
private tcpProbe(wsUrl: string): Promise<boolean> {
return new Promise((resolve) => {
const host = this.extractHost(url);
if (!host) { resolve(false); return; }
const [hostname, portStr] = host.includes(":")
? [host.split(":")[0], host.split(":")[1]]
: [host, "9750"];
const port = parseInt(portStr, 10);
let hostname: string;
let port: number;
try {
// Convert ws/wss to http/https so URL() can parse it
const httpUrl = wsUrl.replace(/^ws(s)?:\/\//, "http$1://");
const parsed = new URL(httpUrl);
hostname = parsed.hostname; // strips IPv6 brackets automatically
port = parsed.port ? parseInt(parsed.port, 10) : 9750;
} catch {
resolve(false);
return;
}
const socket = new Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 5_000);
@ -327,10 +339,6 @@ export class RelayClient {
});
}
private extractHost(url: string): string | null {
return url.replace("wss://", "").replace("ws://", "").split("/")[0] ?? null;
}
private emitStatus(machineId: string, status: ConnectionStatus, error?: string): void {
for (const cb of this.statusListeners) {
try {