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:
parent
c145e37316
commit
0f75cb8e32
12 changed files with 190 additions and 42 deletions
|
|
@ -200,6 +200,9 @@ export class BtmsgDb {
|
||||||
|
|
||||||
// ── Direct messages ──────────────────────────────────────────────────────
|
// ── Direct messages ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #13 (Codex audit): Validate sender and recipient are in the same group.
|
||||||
|
*/
|
||||||
sendMessage(fromAgent: string, toAgent: string, content: string): string {
|
sendMessage(fromAgent: string, toAgent: string, content: string): string {
|
||||||
// Get sender's group_id
|
// Get sender's group_id
|
||||||
const sender = this.db.query<{ group_id: string }, [string]>(
|
const sender = this.db.query<{ group_id: string }, [string]>(
|
||||||
|
|
@ -207,6 +210,15 @@ export class BtmsgDb {
|
||||||
).get(fromAgent);
|
).get(fromAgent);
|
||||||
if (!sender) throw new Error(`Sender agent '${fromAgent}' not found`);
|
if (!sender) throw new Error(`Sender agent '${fromAgent}' not found`);
|
||||||
|
|
||||||
|
// Validate recipient exists and is in the same group
|
||||||
|
const recipient = this.db.query<{ group_id: string }, [string]>(
|
||||||
|
"SELECT group_id FROM agents WHERE id = ?"
|
||||||
|
).get(toAgent);
|
||||||
|
if (!recipient) throw new Error(`Recipient agent '${toAgent}' not found`);
|
||||||
|
if (sender.group_id !== recipient.group_id) {
|
||||||
|
throw new Error(`Cross-group messaging denied: '${fromAgent}' (${sender.group_id}) -> '${toAgent}' (${recipient.group_id})`);
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
this.db.query(
|
this.db.query(
|
||||||
`INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
|
`INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
|
||||||
|
|
@ -249,12 +261,23 @@ export class BtmsgDb {
|
||||||
|
|
||||||
// ── Channels ─────────────────────────────────────────────────────────────
|
// ── Channels ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #13 (Codex audit): Auto-add creator to channel membership on create.
|
||||||
|
*/
|
||||||
createChannel(name: string, groupId: string, createdBy: string): string {
|
createChannel(name: string, groupId: string, createdBy: string): string {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
this.db.query(
|
const tx = this.db.transaction(() => {
|
||||||
`INSERT INTO channels (id, name, group_id, created_by)
|
this.db.query(
|
||||||
VALUES (?1, ?2, ?3, ?4)`
|
`INSERT INTO channels (id, name, group_id, created_by)
|
||||||
).run(id, name, groupId, createdBy);
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
).run(id, name, groupId, createdBy);
|
||||||
|
// Auto-add creator as channel member
|
||||||
|
this.db.query(
|
||||||
|
`INSERT OR IGNORE INTO channel_members (channel_id, agent_id)
|
||||||
|
VALUES (?1, ?2)`
|
||||||
|
).run(id, createdBy);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +315,17 @@ export class BtmsgDb {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #13 (Codex audit): Validate sender is a member of the channel.
|
||||||
|
*/
|
||||||
sendChannelMessage(channelId: string, fromAgent: string, content: string): string {
|
sendChannelMessage(channelId: string, fromAgent: string, content: string): string {
|
||||||
|
const member = this.db.query<{ agent_id: string }, [string, string]>(
|
||||||
|
"SELECT agent_id FROM channel_members WHERE channel_id = ? AND agent_id = ?"
|
||||||
|
).get(channelId, fromAgent);
|
||||||
|
if (!member) {
|
||||||
|
throw new Error(`Agent '${fromAgent}' is not a member of channel '${channelId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
this.db.query(
|
this.db.query(
|
||||||
`INSERT INTO channel_messages (id, channel_id, from_agent, content)
|
`INSERT INTO channel_messages (id, channel_id, from_agent, content)
|
||||||
|
|
|
||||||
|
|
@ -137,14 +137,26 @@ export class BttaskDb {
|
||||||
return expectedVersion + 1;
|
return expectedVersion + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fix #9 (Codex audit): Wrap delete in transaction for atomicity. */
|
||||||
deleteTask(taskId: string): void {
|
deleteTask(taskId: string): void {
|
||||||
this.db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId);
|
const tx = this.db.transaction(() => {
|
||||||
this.db.query("DELETE FROM tasks WHERE id = ?").run(taskId);
|
this.db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId);
|
||||||
|
this.db.query("DELETE FROM tasks WHERE id = ?").run(taskId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Comments ─────────────────────────────────────────────────────────────
|
// ── Comments ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fix #9 (Codex audit): Validate task exists before adding comment. */
|
||||||
addComment(taskId: string, agentId: string, content: string): string {
|
addComment(taskId: string, agentId: string, content: string): string {
|
||||||
|
const task = this.db.query<{ id: string }, [string]>(
|
||||||
|
"SELECT id FROM tasks WHERE id = ?"
|
||||||
|
).get(taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Task '${taskId}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
this.db.query(
|
this.db.query(
|
||||||
`INSERT INTO task_comments (id, task_id, agent_id, content)
|
`INSERT INTO task_comments (id, task_id, agent_id, content)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import { settingsDb } from "../settings-db.ts";
|
import { settingsDb } from "../settings-db.ts";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
@ -29,14 +30,36 @@ function getAllowedRoots(): string[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a file path is within an allowed boundary.
|
* Validate that a file path is within an allowed boundary.
|
||||||
* Returns the resolved path if valid, or null if outside boundaries.
|
* Fix #3 (Codex audit): Uses realpathSync to resolve symlinks, preventing
|
||||||
|
* symlink-based traversal attacks (CWE-59).
|
||||||
|
* Returns the resolved real path if valid, or null if outside boundaries.
|
||||||
*/
|
*/
|
||||||
export function validatePath(filePath: string): string | null {
|
export function validatePath(filePath: string): string | null {
|
||||||
const resolved = path.resolve(filePath);
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
// Resolve symlinks to their actual target to prevent symlink traversal
|
||||||
|
resolved = fs.realpathSync(path.resolve(filePath));
|
||||||
|
} catch {
|
||||||
|
// If the file doesn't exist yet, resolve without symlink resolution
|
||||||
|
// but only allow if the parent directory resolves within boundaries
|
||||||
|
const parent = path.dirname(path.resolve(filePath));
|
||||||
|
try {
|
||||||
|
const realParent = fs.realpathSync(parent);
|
||||||
|
resolved = path.join(realParent, path.basename(filePath));
|
||||||
|
} catch {
|
||||||
|
return null; // Parent doesn't exist either — reject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const roots = getAllowedRoots();
|
const roots = getAllowedRoots();
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const resolvedRoot = path.resolve(root);
|
let resolvedRoot: string;
|
||||||
|
try {
|
||||||
|
resolvedRoot = fs.realpathSync(path.resolve(root));
|
||||||
|
} catch {
|
||||||
|
resolvedRoot = path.resolve(root);
|
||||||
|
}
|
||||||
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import type { RelayClient } from "../relay-client.ts";
|
||||||
|
|
||||||
export function createRemoteHandlers(relayClient: RelayClient) {
|
export function createRemoteHandlers(relayClient: RelayClient) {
|
||||||
return {
|
return {
|
||||||
|
// Fix #4 (Codex audit): relay-client.connect() now returns { ok, machineId, error }
|
||||||
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
|
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
|
||||||
try {
|
try {
|
||||||
const machineId = await relayClient.connect(url, token, label);
|
const result = await relayClient.connect(url, token, label);
|
||||||
return { ok: true, machineId };
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
console.error("[remote.connect]", err);
|
console.error("[remote.connect]", err);
|
||||||
|
|
@ -28,6 +29,18 @@ export function createRemoteHandlers(relayClient: RelayClient) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fix #6 (Codex audit): Add remote.remove RPC that disconnects AND deletes
|
||||||
|
"remote.remove": ({ machineId }: { machineId: string }) => {
|
||||||
|
try {
|
||||||
|
relayClient.removeMachine(machineId);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[remote.remove]", err);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"remote.list": () => {
|
"remote.list": () => {
|
||||||
try {
|
try {
|
||||||
return { machines: relayClient.listMachines() };
|
return { machines: relayClient.listMachines() };
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export interface SessionInfo {
|
||||||
export type DaemonEvent =
|
export type DaemonEvent =
|
||||||
| { type: "auth_result"; ok: boolean }
|
| { type: "auth_result"; ok: boolean }
|
||||||
| { type: "session_created"; session_id: string; pid: number }
|
| { type: "session_created"; session_id: string; pid: number }
|
||||||
/** data is base64-encoded bytes from the PTY. */
|
/** data is base64-encoded raw bytes from the PTY daemon. Decoded to Uint8Array by the consumer. */
|
||||||
| { type: "session_output"; session_id: string; data: string }
|
| { type: "session_output"; session_id: string; data: string }
|
||||||
| { type: "session_closed"; session_id: string; exit_code: number | null }
|
| { type: "session_closed"; session_id: string; exit_code: number | null }
|
||||||
| { type: "session_list"; sessions: SessionInfo[] }
|
| { type: "session_list"; sessions: SessionInfo[] }
|
||||||
|
|
@ -60,6 +60,9 @@ export class PtyClient extends EventEmitter {
|
||||||
|
|
||||||
/** Connect to daemon and authenticate. Fix #10: 5-second timeout. */
|
/** Connect to daemon and authenticate. Fix #10: 5-second timeout. */
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
|
// Fix #10 (Codex audit): Clear stale buffer from any previous connection
|
||||||
|
this.buffer = "";
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let token: string;
|
let token: string;
|
||||||
try {
|
try {
|
||||||
|
|
@ -157,14 +160,24 @@ export class PtyClient extends EventEmitter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write input to a session's PTY. data is encoded as base64 for transport. */
|
/**
|
||||||
|
* Write input to a session's PTY. data is encoded as base64 for transport.
|
||||||
|
* Fix #11 (Codex audit): Uses chunked approach instead of String.fromCharCode(...spread)
|
||||||
|
* which can throw RangeError on large pastes (>~65K bytes).
|
||||||
|
*/
|
||||||
writeInput(sessionId: string, data: string | Uint8Array): void {
|
writeInput(sessionId: string, data: string | Uint8Array): void {
|
||||||
const bytes =
|
const bytes =
|
||||||
typeof data === "string"
|
typeof data === "string"
|
||||||
? new TextEncoder().encode(data)
|
? new TextEncoder().encode(data)
|
||||||
: data;
|
: data;
|
||||||
// Daemon expects base64 string per protocol.rs WriteInput { data: String }
|
// Daemon expects base64 string per protocol.rs WriteInput { data: String }
|
||||||
const b64 = btoa(String.fromCharCode(...bytes));
|
const CHUNK_SIZE = 8192;
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
const b64 = btoa(binary);
|
||||||
this.send({ type: "write_input", session_id: sessionId, data: b64 });
|
this.send({ type: "write_input", session_id: sessionId, data: b64 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,12 @@ export class RelayClient {
|
||||||
this.statusListeners.push(cb);
|
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 machineId = randomUUID();
|
||||||
const machine: MachineConnection = {
|
const machine: MachineConnection = {
|
||||||
machineId,
|
machineId,
|
||||||
|
|
@ -84,14 +88,14 @@ export class RelayClient {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.openWebSocket(machine);
|
await this.openWebSocket(machine);
|
||||||
|
return { ok: true, machineId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
machine.status = "error";
|
machine.status = "error";
|
||||||
this.emitStatus(machineId, "error", msg);
|
this.emitStatus(machineId, "error", msg);
|
||||||
this.scheduleReconnect(machine);
|
this.scheduleReconnect(machine);
|
||||||
|
return { ok: false, machineId, error: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
return machineId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disconnect from a relay and stop reconnection attempts. */
|
/** Disconnect from a relay and stop reconnection attempts. */
|
||||||
|
|
@ -299,16 +303,24 @@ export class RelayClient {
|
||||||
machine.reconnectTimer = setTimeout(attempt, delay);
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const host = this.extractHost(url);
|
let hostname: string;
|
||||||
if (!host) { resolve(false); return; }
|
let port: number;
|
||||||
|
try {
|
||||||
const [hostname, portStr] = host.includes(":")
|
// Convert ws/wss to http/https so URL() can parse it
|
||||||
? [host.split(":")[0], host.split(":")[1]]
|
const httpUrl = wsUrl.replace(/^ws(s)?:\/\//, "http$1://");
|
||||||
: [host, "9750"];
|
const parsed = new URL(httpUrl);
|
||||||
const port = parseInt(portStr, 10);
|
hostname = parsed.hostname; // strips IPv6 brackets automatically
|
||||||
|
port = parsed.port ? parseInt(parsed.port, 10) : 9750;
|
||||||
|
} catch {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const socket = new Socket();
|
const socket = new Socket();
|
||||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 5_000);
|
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 {
|
private emitStatus(machineId: string, status: ConnectionStatus, error?: string): void {
|
||||||
for (const cb of this.statusListeners) {
|
for (const cb of this.statusListeners) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ function findNodeRuntime(): string {
|
||||||
// ── Cleanup grace period ─────────────────────────────────────────────────────
|
// ── Cleanup grace period ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session
|
const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session
|
||||||
|
// Fix #12 (Codex audit): Max NDJSON line size — prevent OOM on malformed output
|
||||||
|
const MAX_LINE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
// ── SidecarManager ───────────────────────────────────────────────────────────
|
// ── SidecarManager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -369,6 +371,13 @@ export class SidecarManager {
|
||||||
for await (const chunk of reader) {
|
for await (const chunk of reader) {
|
||||||
buffer += decoder.decode(chunk, { stream: true });
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
|
||||||
|
// Fix #12 (Codex audit): Guard against unbounded buffer growth
|
||||||
|
if (buffer.length > MAX_LINE_SIZE && !buffer.includes("\n")) {
|
||||||
|
console.error(`[sidecar] Buffer exceeded ${MAX_LINE_SIZE} bytes without newline for ${sessionId}, truncating`);
|
||||||
|
buffer = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let newlineIdx: number;
|
let newlineIdx: number;
|
||||||
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||||
const line = buffer.slice(0, newlineIdx).trim();
|
const line = buffer.slice(0, newlineIdx).trim();
|
||||||
|
|
@ -379,7 +388,7 @@ export class SidecarManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix #12: Parse any residual data left in the buffer after stream ends
|
// Parse any residual data left in the buffer after stream ends
|
||||||
const residual = buffer.trim();
|
const residual = buffer.trim();
|
||||||
if (residual) {
|
if (residual) {
|
||||||
this.handleNdjsonLine(sessionId, session, residual);
|
this.handleNdjsonLine(sessionId, session, residual);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@
|
||||||
let draggedTaskId = $state<string | null>(null);
|
let draggedTaskId = $state<string | null>(null);
|
||||||
let dragOverCol = $state<string | null>(null);
|
let dragOverCol = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Fix #7 (Codex audit): Poll token to discard stale responses
|
||||||
|
let pollToken = $state(0);
|
||||||
|
|
||||||
// ── Derived ──────────────────────────────────────────────────────────
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let tasksByCol = $derived(
|
let tasksByCol = $derived(
|
||||||
|
|
@ -58,8 +61,11 @@
|
||||||
// ── Data fetching ────────────────────────────────────────────────────
|
// ── Data fetching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
|
// Fix #7 (Codex audit): Capture token before async call, discard if stale
|
||||||
|
const tokenAtStart = ++pollToken;
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
||||||
|
if (tokenAtStart < pollToken) return; // Stale response — discard
|
||||||
tasks = res.tasks;
|
tasks = res.tasks;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TaskBoard] loadTasks:', err);
|
console.error('[TaskBoard] loadTasks:', err);
|
||||||
|
|
@ -98,6 +104,7 @@
|
||||||
const task = tasks.find(t => t.id === taskId);
|
const task = tasks.find(t => t.id === taskId);
|
||||||
if (!task || task.status === newStatus) return;
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
|
pollToken++; // Fix #7: Invalidate in-flight polls
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['bttask.updateTaskStatus']({
|
const res = await appRpc.request['bttask.updateTaskStatus']({
|
||||||
taskId,
|
taskId,
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,8 @@ function ensureListeners() {
|
||||||
}
|
}
|
||||||
persistMessages(session);
|
persistMessages(session);
|
||||||
scheduleCleanup(session.sessionId, session.projectId);
|
scheduleCleanup(session.sessionId, session.projectId);
|
||||||
|
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
||||||
|
enforceMaxSessions(session.projectId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ interface ProjectTracker {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
lastActivityTs: number;
|
lastActivityTs: number;
|
||||||
lastToolName: string | null;
|
lastToolName: string | null;
|
||||||
toolInFlight: boolean;
|
// Fix #8 (Codex audit): Counter instead of boolean for concurrent tools
|
||||||
|
toolsInFlight: number;
|
||||||
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
|
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
|
|
@ -87,7 +88,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
|
|
||||||
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
|
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
|
||||||
activityState = 'inactive';
|
activityState = 'inactive';
|
||||||
} else if (tracker.toolInFlight) {
|
} else if (tracker.toolsInFlight > 0) {
|
||||||
activityState = 'running';
|
activityState = 'running';
|
||||||
activeTool = tracker.lastToolName;
|
activeTool = tracker.lastToolName;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -125,7 +126,7 @@ export function trackProject(projectId: string): void {
|
||||||
projectId,
|
projectId,
|
||||||
lastActivityTs: Date.now(),
|
lastActivityTs: Date.now(),
|
||||||
lastToolName: null,
|
lastToolName: null,
|
||||||
toolInFlight: false,
|
toolsInFlight: 0,
|
||||||
costSnapshots: [],
|
costSnapshots: [],
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
totalCost: 0,
|
totalCost: 0,
|
||||||
|
|
@ -142,7 +143,8 @@ export function recordActivity(projectId: string, toolName?: string): void {
|
||||||
t.status = 'running';
|
t.status = 'running';
|
||||||
if (toolName !== undefined) {
|
if (toolName !== undefined) {
|
||||||
t.lastToolName = toolName;
|
t.lastToolName = toolName;
|
||||||
t.toolInFlight = true;
|
// Fix #8 (Codex audit): Increment counter for concurrent tool tracking
|
||||||
|
t.toolsInFlight++;
|
||||||
}
|
}
|
||||||
if (!tickInterval) startHealthTick();
|
if (!tickInterval) startHealthTick();
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +154,8 @@ export function recordToolDone(projectId: string): void {
|
||||||
const t = trackers.get(projectId);
|
const t = trackers.get(projectId);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
t.lastActivityTs = Date.now();
|
t.lastActivityTs = Date.now();
|
||||||
t.toolInFlight = false;
|
// Fix #8 (Codex audit): Decrement counter, floor at 0
|
||||||
|
t.toolsInFlight = Math.max(0, t.toolsInFlight - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a token/cost snapshot for burn rate calculation. */
|
/** Record a token/cost snapshot for burn rate calculation. */
|
||||||
|
|
@ -220,6 +223,16 @@ function startHealthTick(): void {
|
||||||
}, TICK_INTERVAL_MS);
|
}, TICK_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Untrack a project (e.g. when project is removed). Fix #14 (Codex audit). */
|
||||||
|
export function untrackProject(projectId: string): void {
|
||||||
|
trackers.delete(projectId);
|
||||||
|
// Stop tick if no more tracked projects
|
||||||
|
if (trackers.size === 0 && tickInterval) {
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
tickInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Stop the health tick timer. */
|
/** Stop the health tick timer. */
|
||||||
export function stopHealthTick(): void {
|
export function stopHealthTick(): void {
|
||||||
if (tickInterval) {
|
if (tickInterval) {
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,11 @@
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix #6 (Codex audit): Use remote.remove RPC that disconnects AND deletes
|
||||||
async function handleRemove(machineId: string) {
|
async function handleRemove(machineId: string) {
|
||||||
try {
|
try {
|
||||||
await appRpc.request['remote.disconnect']({ machineId });
|
await appRpc.request['remote.remove']({ machineId });
|
||||||
} catch { /* may already be disconnected */ }
|
} catch { /* may already be disconnected/removed */ }
|
||||||
removeMachine(machineId);
|
removeMachine(machineId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +132,9 @@
|
||||||
<span class="status-text" style:color={statusColor(m.status)}>
|
<span class="status-text" style:color={statusColor(m.status)}>
|
||||||
{statusLabel(m.status)}
|
{statusLabel(m.status)}
|
||||||
</span>
|
</span>
|
||||||
|
{#if m.error}
|
||||||
|
<span class="machine-error" title={m.error}>{m.error}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="machine-actions">
|
<div class="machine-actions">
|
||||||
{#if m.status === 'connected'}
|
{#if m.status === 'connected'}
|
||||||
|
|
@ -239,6 +243,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text { font-size: 0.6875rem; font-weight: 500; }
|
.status-text { font-size: 0.6875rem; font-weight: 500; }
|
||||||
|
.machine-error { font-size: 0.625rem; color: var(--ctp-red); max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
.machine-actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
|
.machine-actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,16 @@ export type PtyRPCRequests = {
|
||||||
};
|
};
|
||||||
response: { ok: boolean; error?: string };
|
response: { ok: boolean; error?: string };
|
||||||
};
|
};
|
||||||
/** Write raw input bytes (base64-encoded) to a PTY session. */
|
/**
|
||||||
|
* Write input to a PTY session.
|
||||||
|
* `data` is raw UTF-8 text from the user (xterm onData). The pty-client
|
||||||
|
* layer encodes it to base64 before sending to the daemon; this RPC boundary
|
||||||
|
* carries raw text which the Bun handler forwards to PtyClient.writeInput().
|
||||||
|
*/
|
||||||
"pty.write": {
|
"pty.write": {
|
||||||
params: {
|
params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** UTF-8 text typed by the user (xterm onData delivers this). */
|
/** Raw UTF-8 text typed by the user (xterm onData delivers this). Encoded to base64 by pty-client before daemon transport. */
|
||||||
data: string;
|
data: string;
|
||||||
};
|
};
|
||||||
response: { ok: boolean };
|
response: { ok: boolean };
|
||||||
|
|
@ -549,11 +554,16 @@ export type PtyRPCRequests = {
|
||||||
params: { url: string; token: string; label?: string };
|
params: { url: string; token: string; label?: string };
|
||||||
response: { ok: boolean; machineId?: string; error?: string };
|
response: { ok: boolean; machineId?: string; error?: string };
|
||||||
};
|
};
|
||||||
/** Disconnect from a relay instance. */
|
/** Disconnect from a relay instance (keeps machine in list for reconnect). */
|
||||||
"remote.disconnect": {
|
"remote.disconnect": {
|
||||||
params: { machineId: string };
|
params: { machineId: string };
|
||||||
response: { ok: boolean; error?: string };
|
response: { ok: boolean; error?: string };
|
||||||
};
|
};
|
||||||
|
/** Remove a machine entirely — disconnects AND deletes from tracking. */
|
||||||
|
"remote.remove": {
|
||||||
|
params: { machineId: string };
|
||||||
|
response: { ok: boolean; error?: string };
|
||||||
|
};
|
||||||
/** List all known remote machines with connection status. */
|
/** List all known remote machines with connection status. */
|
||||||
"remote.list": {
|
"remote.list": {
|
||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue