diff --git a/CLAUDE.md b/CLAUDE.md index 6bd3a7c..90243ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,5 +156,11 @@ broken on some Windows 11 builds (stuck in `playState 9`). The local STT provider uses whisper.cpp via the `nodejs-whisper` package: - **Windows:** Prebuilt `whisper-cli` binary is auto-downloaded from GitHub releases (v1.8.3). -- **macOS/Linux:** Built from source via CMake (requires build tools). +- **macOS/Linux:** Built from source via CMake. On macOS, Xcode Command Line + Tools alone are not enough — CMake must be installed separately + (`brew install cmake`). - **All platforms:** Requires FFmpeg on PATH for audio conversion. +- **Bundling:** `nodejs-whisper` must stay in the esbuild `external` list in + `scripts/build-mcp.mjs` (and in root `package.json` dependencies so the + bundle can resolve it). Inlining it breaks `__dirname` resolution and the + path to its bundled whisper.cpp tree. diff --git a/bin/devglide.js b/bin/devglide.js index e7160d3..b3ecbfd 100755 --- a/bin/devglide.js +++ b/bin/devglide.js @@ -280,27 +280,70 @@ function runMcpServer(name) { // --- Unified server --- -function startServer(foreground = false) { +/** Probe the health endpoint. Returns the health JSON, or null if nothing answers. */ +async function probeHealth() { + try { + const res = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/health`, { + signal: AbortSignal.timeout(1500), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +/** Print the last lines of the server log to help diagnose a failed start. */ +function printLogTail(lines = 15) { + try { + const logFile = resolve(logDir, "server.log"); + const content = readFileSync(logFile, "utf8").trimEnd().split("\n"); + const tail = content.slice(-lines); + if (tail.length) { + console.error(`\n Last ${tail.length} log lines (${logFile}):`); + for (const line of tail) console.error(` ${line}`); + } + } catch { /* no log yet */ } +} + +async function startServer(foreground = false) { const { running, pid } = getStatus("server"); if (running) { console.log(` server already running (pid ${pid})`); return; } - const tsxBin = resolve(root, "node_modules/.bin/tsx"); - if (!existsSync(tsxBin)) { + // Preflight: the pid file may be stale while a *different* process still + // holds the port (e.g. a server started from another working copy). + // Starting on top of it would exit with EADDRINUSE after we already + // reported success — detect and refuse instead. + const existing = await probeHealth(); + if (existing) { + console.error( + ` port :${SERVER_PORT} is already served by another devglide process` + + (existing.pid ? ` (pid ${existing.pid})` : "") + + ` not tracked by this CLI.` + ); + console.error(` Stop it first (devglide stop, or kill the pid above), then retry.`); + process.exitCode = 1; + return; + } + + // Spawn tsx via its JS entry with the current node binary — no shell. + // shell:true combined with detached:true silently discards fd-redirected + // stdio on Windows (cmd.exe attaches to a fresh console), which made the + // daemon log permanently empty and startup failures undiagnosable. + const tsxCli = resolve(root, "node_modules/tsx/dist/cli.mjs"); + if (!existsSync(tsxCli)) { console.error("tsx not found. Run 'pnpm install' first."); process.exit(1); } if (foreground) { - // Quote tsxBin so a path with spaces survives shell:true (needed on Windows - // to resolve the extensionless `.bin/tsx` launcher). - const child = spawn(`"${tsxBin}"`, ["src/server.ts"], { + const child = spawn(process.execPath, [tsxCli, "src/server.ts"], { cwd: root, stdio: "inherit", env: { ...process.env, PORT: String(SERVER_PORT) }, - shell: true, }); child.on("exit", (code) => process.exit(code ?? 1)); return; @@ -309,18 +352,38 @@ function startServer(foreground = false) { // Background daemon const logFile = resolve(logDir, "server.log"); const out = openSync(logFile, "a"); - // Quote tsxBin so a path with spaces survives shell:true. - const child = spawn(`"${tsxBin}"`, ["src/server.ts"], { + const child = spawn(process.execPath, [tsxCli, "src/server.ts"], { cwd: root, stdio: ["ignore", out, out], env: { ...process.env, PORT: String(SERVER_PORT) }, detached: true, - shell: true, }); savePid("server", child.pid); child.unref(); closeSync(out); - console.log(` server started on :${SERVER_PORT} (pid ${child.pid})`); + + // Verify the daemon actually came up before claiming success: the child + // must stay alive AND the health endpoint must answer. + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (!isRunning(child.pid)) { + removePid("server"); + console.error(` server failed to start — process exited during startup.`); + printLogTail(); + process.exitCode = 1; + return; + } + if (await probeHealth()) { + console.log(` server started on :${SERVER_PORT} (pid ${child.pid})`); + return; + } + await new Promise((r) => setTimeout(r, 250)); + } + + removePid("server"); + console.error(` server did not become ready on :${SERVER_PORT} within 15s.`); + printLogTail(); + process.exitCode = 1; } function killTree(pid, signal) { @@ -676,7 +739,7 @@ ${Object.keys(mcpServers).map((name) => ` ${name}`).join("\n")} switch (command) { case "dev": - startServer(true); + startServer(true).catch((err) => { console.error(err); process.exitCode = 1; }); break; case "start": @@ -684,7 +747,7 @@ switch (command) { if (args[0] === "stop") { stopServer(); } else { - startServer(command === "dev"); + startServer(command === "dev").catch((err) => { console.error(err); process.exitCode = 1; }); } break; @@ -697,8 +760,8 @@ switch (command) { // starting the new one, otherwise startServer can hit EADDRINUSE. (async () => { await stopServer(); - startServer(false); - })(); + await startServer(false); + })().catch((err) => { console.error(err); process.exitCode = 1; }); break; case "status": diff --git a/package.json b/package.json index 7e8dfdb..abde931 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "express": "^5.2.1", "multer": "1.4.5-lts.2", "node-pty": "^1.1.0", + "nodejs-whisper": "^0.2.9", "openai": "^4.104.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1f9ee..c92a412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + nodejs-whisper: + specifier: ^0.2.9 + version: 0.2.9 openai: specifier: ^4.104.0 version: 4.104.0(ws@8.19.0)(zod@3.25.76) diff --git a/scripts/build-mcp.mjs b/scripts/build-mcp.mjs index a8c9989..fa585ab 100644 --- a/scripts/build-mcp.mjs +++ b/scripts/build-mcp.mjs @@ -23,7 +23,9 @@ const servers = [ // Native / optional packages must not be inlined by esbuild. nodejs-whisper pulls in // whisper.cpp and is an optional STT dependency that may be absent or fail to install; // the voice provider imports it lazily and degrades gracefully at runtime. Bundling it -// makes the build fail with "Could not resolve 'nodejs-whisper'" when it is not present. +// makes the build fail with "Could not resolve 'nodejs-whisper'" when it is not present — +// and even when present, its CJS code references __dirname (undefined in ESM bundle +// scope) and resolves its whisper.cpp tree relative to its own install location. const external = ["better-sqlite3", "node-pty", "nodejs-whisper"]; // CJS packages bundled into ESM need a real require() for Node built-ins diff --git a/src/apps/chat/services/chat-registry.targeted-delivery.test.ts b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts index 4162b27..009307b 100644 --- a/src/apps/chat/services/chat-registry.targeted-delivery.test.ts +++ b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts @@ -78,6 +78,15 @@ describe('parseTargetTokens', () => { expect(registry.parseTargetTokens('@claude-7 and @claude-7 again')).toEqual(['claude-7']); }); + it('ignores mid-word @ (emails must not become mentions)', () => { + expect(registry.parseTargetTokens('ping admin@example.com about the outage')).toEqual([]); + expect(registry.parseTargetTokens('contact a@b and c@d please')).toEqual([]); + }); + + it('still extracts a mention at start or after brackets', () => { + expect(registry.parseTargetTokens('(@claude-7) take this')).toEqual(['claude-7']); + }); + it('merges explicit to param for user senders', () => { expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'user')).toEqual(['claude-7', 'codex-14']); }); diff --git a/src/apps/chat/services/chat-registry.ts b/src/apps/chat/services/chat-registry.ts index bdb2767..28c018b 100644 --- a/src/apps/chat/services/chat-registry.ts +++ b/src/apps/chat/services/chat-registry.ts @@ -756,10 +756,13 @@ export async function send(from: string, body: string, to?: string, projectId?: if (plan.recipients.length > 0) { expectedDeliveryCount = plan.recipients.length; } else if (plan.fallbackBroadcast) { - // Count broadcast targets (Option B fallback) + // Count broadcast targets (Option B fallback). Exclude detached + // participants — deliverToPty skips them, and counting them reports + // deliveries that never happened (e.g. restored-but-unreclaimed agents + // after a server restart). expectedDeliveryCount = 0; for (const p of participants.values()) { - if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) { + if (p.name !== from && p.paneId && !p.detached && p.projectId === resolvedSenderProjectId) { expectedDeliveryCount++; } } @@ -790,8 +793,9 @@ export async function send(from: string, body: string, to?: string, projectId?: } } else if (plan.fallbackBroadcast) { // Option B: unaddressed user/system messages still broadcast + // (skip detached participants — matches the count above and deliverToPty) for (const p of participants.values()) { - if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) { + if (p.name !== from && p.paneId && !p.detached && p.projectId === resolvedSenderProjectId) { await deliverToPty(p.name, resolvedSenderProjectId, msg); } } @@ -860,8 +864,13 @@ export function parseTargetTokens(body: string, to?: string, senderKind?: 'user' // in DevGlide use `-` as the separator. This is the parser-side // defense; `buildDeliveryPlan` adds a second defense by filtering // tokens that don't resolve to a real participant. + // The @ must additionally NOT be preceded by an alphanumeric/./@/- — + // a mid-word @ is an email or host token ("admin@example.com", "a@b"), + // and treating it as a mention both mistargets delivery and suppresses + // the unaddressed-broadcast fallback. Markdown decoration (* _ ~) and + // brackets still count as valid boundaries. const scannable = stripCodeRegions(body); - const regex = /@([a-zA-Z0-9-]+)/g; + const regex = /(?:^|[^A-Za-z0-9.@-])@([a-zA-Z0-9-]+)/g; let match: RegExpExecArray | null; while ((match = regex.exec(scannable)) !== null) { const token = match[1]; @@ -987,7 +996,18 @@ function deliverToPty(targetName: string, projectId: string | null, msg: ChatMes } const header = formatPtyHeader(targetName, msg); - let formatted = `${header} @${msg.from}: ${msg.body}`; + // Sanitize the body before it reaches another agent's terminal: + // - \r would submit attacker-chosen partial input immediately on + // line-based CLIs, defeating the deliberate delayed-submit design + // - ANSI escapes pass through to the recipient's TUI + // - a line matching the injected-header pattern would forge the + // server-controlled authority header ("Assigned by: pipe") + const sanitizedBody = stripAnsi(msg.body) + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => (isChatInjectedOutput(line) ? `> ${line}` : line)) + .join('\n'); + let formatted = `${header} @${msg.from}: ${sanitizedBody}`; // Write with retry — if the initial write fails, retry once after a short delay let writeOk = false; @@ -1097,7 +1117,10 @@ export function getParticipantByPaneId(paneId: string, projectId?: string | null /** Clear chat history for the active project and notify dashboard clients. */ export function clearHistory(projectId?: string | null): void { const pid = resolveProjectId(projectId); - clearMessages(pid); + // Preserve the persistence files of pipes that are still running — their + // event logs are the crash-recovery source of truth. + const running = new Set(pipeStore.listActivePipes(pid).map((p) => p.pipeId)); + clearMessages(pid, running); emitToProject('chat:cleared', {}, pid); } diff --git a/src/apps/chat/services/chat-store.ts b/src/apps/chat/services/chat-store.ts index 7fd3238..cc01521 100644 --- a/src/apps/chat/services/chat-store.ts +++ b/src/apps/chat/services/chat-store.ts @@ -24,6 +24,8 @@ function getMessagesPath(projectId?: string | null): string | null { } function getPipeMessagesPath(pipeId: string, projectId?: string | null): string | null { + // Same traversal guard as getPipeEventsPath — the id is joined into a path. + if (!/^[\w-]+$/.test(pipeId)) return null; const dir = getChatDir(projectId); if (!dir) return null; const pipesDir = join(dir, 'pipes'); @@ -39,6 +41,9 @@ function getPipeEventsLogPath(projectId?: string | null): string | null { } function getPipeEventsPath(pipeId: string, projectId?: string | null): string | null { + // The id is joined into a filesystem path — allow only word chars and + // dashes (no separators, no dots) so it cannot traverse out of pipes/. + if (!/^[\w-]+$/.test(pipeId)) return null; const dir = getChatDir(projectId); if (!dir) return null; const pipesDir = join(dir, 'pipes'); @@ -228,7 +233,7 @@ export function loadParticipants(projectId?: string | null): PersistedParticipan } -export function clearMessages(projectId?: string | null): void { +export function clearMessages(projectId?: string | null, keepPipeIds?: ReadonlySet): void { const filePath = getMessagesPath(projectId); if (filePath && existsSync(filePath)) { writeFileSync(filePath, ''); @@ -237,15 +242,18 @@ export function clearMessages(projectId?: string | null): void { if (pipeEventsPath && existsSync(pipeEventsPath)) { unlinkSync(pipeEventsPath); } - // Also clear per-pipe JSONL files + // Also clear per-pipe JSONL files — EXCEPT those of pipes the caller marks + // as still running: their .events.jsonl is the crash-recovery source, and + // deleting it mid-run silently drops the pipe on the next restart. const dir = getChatDir(projectId); if (dir) { const pipesDir = join(dir, 'pipes'); if (existsSync(pipesDir)) { for (const file of readdirSync(pipesDir)) { - if (file.endsWith('.jsonl')) { - unlinkSync(join(pipesDir, file)); - } + if (!file.endsWith('.jsonl')) continue; + const pipeId = file.replace(/\.events\.jsonl$|\.jsonl$/, ''); + if (keepPipeIds?.has(pipeId)) continue; + unlinkSync(join(pipesDir, file)); } } } diff --git a/src/apps/chat/services/pipe-store.test.ts b/src/apps/chat/services/pipe-store.test.ts index 1f47e4d..b9220cf 100644 --- a/src/apps/chat/services/pipe-store.test.ts +++ b/src/apps/chat/services/pipe-store.test.ts @@ -474,6 +474,21 @@ describe("pipe-store lease expiry enforcement", () => { expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false); }); + it("isLeaseExpired returns true when past deadline", () => { + // Pin the timeout explicitly — the check must not depend on DEFAULT_STAGE_TIMEOUT_MS. + pipeStore.createPipe( + "pipe-1", + "linear", + ["alice", "bob", "carol"], + "test prompt", + "proj-1", + { stageTimeoutMs: 60_000 }, + ); + const result = pipeStore.grantLease("pipe-1", "alice", "proj-1"); + const pastDeadline = Date.now() + 2 * 60_000; + expect(pipeStore.isLeaseExpired(result.lease!, pastDeadline)).toBe(true); + }); + it("submitStage accepts submission with active lease", () => { createLinearPipe(); pipeStore.grantLease("pipe-1", "alice", "proj-1"); diff --git a/src/apps/chat/src/mcp.ts b/src/apps/chat/src/mcp.ts index cc4bf1a..8fe2a92 100644 --- a/src/apps/chat/src/mcp.ts +++ b/src/apps/chat/src/mcp.ts @@ -72,6 +72,22 @@ export function registerChatMcpHttpSession(sessionId: string, server: McpServer) getServerState(server); } +/** + * True when a DIFFERENT live MCP session currently tracks the same + * participant. Used by onSessionClose: a stale session closing (TTL reap, + * late clean close) must not detach a participant that has since been + * reclaimed under a new session — that would fail its running pipes and + * eject a live agent. + */ +export function isNameTrackedByAnotherSession(server: McpServer, name: string, projectId: string | null): boolean { + for (const other of chatMcpServersBySessionId.values()) { + if (other === server) continue; + const entry = chatMcpServerStates.get(other)?.sessionEntry; + if (entry && entry.name === name && entry.projectId === projectId) return true; + } + return false; +} + export function unregisterChatMcpHttpSession(server: McpServer, sessionId?: string): void { if (sessionId) { if (chatMcpServersBySessionId.get(sessionId) === server) chatMcpServersBySessionId.delete(sessionId); @@ -479,7 +495,11 @@ export function createChatMcpServer(): McpServer { async () => { // Use REST API for consistent behavior — direct registry calls can miss // participants when sessionProjectId is null (before join or after restart). - const res = await chatApi('/members'); + // Forward the session's project like every other tool — the router + // otherwise falls back to the dashboard's ACTIVE project, which may not + // be the project this agent joined from. + const pid = getSessionProjectId(); + const res = await chatApi(pid ? `/members?projectId=${encodeURIComponent(pid)}` : '/members'); if (!res.ok) return errorResult('Failed to fetch members'); return jsonResult(res.data); }, diff --git a/src/apps/documentation/services/documentation-store.ts b/src/apps/documentation/services/documentation-store.ts index 11e698e..50ab7b2 100644 --- a/src/apps/documentation/services/documentation-store.ts +++ b/src/apps/documentation/services/documentation-store.ts @@ -137,21 +137,57 @@ export class DocumentationStore extends JsonFileStore { } as DocEntry; let scope = inputScope; + let scopeProjectId = getActiveProject()?.id; if (!scope && isUpdate) { - scope = await this.resolveExistingScope(input.id!); + // locateExisting (not resolveExistingScope) so a stdio MCP process + // with no active project updates the owning project's copy in place + // instead of writing a global duplicate that the stale project copy + // then shadows. + const located = await this.locateExisting(input.id!); + if (located) { + scope = located.scope; + scopeProjectId = located.projectId ?? scopeProjectId; + } } scope = scope ?? (getActiveProject() ? 'project' : 'global'); - const activeProjectId = getActiveProject()?.id; // Record the owning project on project-scoped entries so // getCompiledContext can filter out other projects' overrides. - entry.projectId = scope === 'project' && activeProjectId ? activeProjectId : undefined; + entry.projectId = scope === 'project' && scopeProjectId ? scopeProjectId : undefined; - await this.writeEntity(entry, scope, activeProjectId); + await this.writeEntity(entry, scope, scopeProjectId); return entry; }); } + /** + * Atomically fetch, merge, and write — eliminates the get→merge→save TOCTOU + * race in callers. undefined = keep existing, null = clear field. + */ + async update( + id: string, + fields: { [K in keyof Omit]?: DocEntry[K] | null }, + ): Promise { + return this.withLock(id, async () => { + const existing = await this.get(id); + if (!existing) return null; + + const merged: DocEntry = { ...existing, updatedAt: new Date().toISOString() }; + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue; + (merged as unknown as Record)[key] = value === null ? undefined : value; + } + this.validateEntry(merged as unknown as Record); + + const located = await this.locateExisting(id); + const scope = located?.scope ?? (getActiveProject() ? 'project' : 'global'); + const scopeProjectId = located?.projectId ?? getActiveProject()?.id; + merged.projectId = scope === 'project' && scopeProjectId ? scopeProjectId : undefined; + await this.writeEntity(merged, scope, scopeProjectId); + return merged; + }); + } + // ── Compiled context ────────────────────────────────────────────────────── async getCompiledContext(query?: string, projectId?: string): Promise { diff --git a/src/apps/documentation/src/mcp.ts b/src/apps/documentation/src/mcp.ts index d2873b8..f20063b 100644 --- a/src/apps/documentation/src/mcp.ts +++ b/src/apps/documentation/src/mcp.ts @@ -177,19 +177,22 @@ export function createDocumentationMcpServer(): McpServer { content: z.string().describe('JSON string with the fields to update'), }, async ({ id, content }) => { - const existing = await store.get(id); - if (!existing) return errorResult('Entry not found'); - let updates: Record; try { updates = JSON.parse(content); } catch { return errorResult('Invalid JSON in content field'); } + // Never let a payload change identity or type + delete updates.id; + delete updates.type; + delete updates.projectId; - const merged = { ...existing, ...updates, id, type: existing.type }; try { - const entry = await store.save(merged as any); + // Atomic read-merge-write inside the store lock — a separate + // get()+save() here loses concurrent field updates. + const entry = await store.update(id, updates as Parameters[1]); + if (!entry) return errorResult('Entry not found'); return jsonResult(entry); } catch (err) { return errorResult(`Validation failed: ${(err as Error).message}`); diff --git a/src/apps/kanban/src/db.ts b/src/apps/kanban/src/db.ts index 73a50c4..6c59441 100644 --- a/src/apps/kanban/src/db.ts +++ b/src/apps/kanban/src/db.ts @@ -262,16 +262,23 @@ export function appendVersionedEntry( type: string, content: string ): VersionedEntryRow | undefined { - const maxVersion = db - .prepare( - `SELECT MAX("version") AS maxVer FROM "VersionedEntry" WHERE "issueId" = ? AND "type" = ?` - ) - .get(issueId, type) as { maxVer: number | null } | undefined; - const version = (maxVersion?.maxVer ?? 0) + 1; + // MAX+1 and INSERT must be one transaction — the HTTP server and a stdio + // MCP process share this WAL database, and separate implicit transactions + // can hand both writers the same version number. IMMEDIATE takes the write + // lock at BEGIN so the SELECT already sees a stable MAX. const id = generateId(); - db.prepare( - `INSERT INTO "VersionedEntry" ("id", "issueId", "type", "version", "content") VALUES (?, ?, ?, ?, ?)` - ).run(id, issueId, type, version, content); + const txn = db.transaction(() => { + const maxVersion = db + .prepare( + `SELECT MAX("version") AS maxVer FROM "VersionedEntry" WHERE "issueId" = ? AND "type" = ?` + ) + .get(issueId, type) as { maxVer: number | null } | undefined; + const version = (maxVersion?.maxVer ?? 0) + 1; + db.prepare( + `INSERT INTO "VersionedEntry" ("id", "issueId", "type", "version", "content") VALUES (?, ?, ?, ?, ?)` + ).run(id, issueId, type, version, content); + }); + txn.immediate(); return db.prepare(`SELECT * FROM "VersionedEntry" WHERE "id" = ?`).get(id) as VersionedEntryRow | undefined; } diff --git a/src/apps/kanban/src/routes/attachments.ts b/src/apps/kanban/src/routes/attachments.ts index 46fbb59..282b5e2 100644 --- a/src/apps/kanban/src/routes/attachments.ts +++ b/src/apps/kanban/src/routes/attachments.ts @@ -55,8 +55,21 @@ const attachmentIdParamSchema = z.object({ id: z.string().min(1, "attachment id is required"), }); +/** Run the multer middleware and map its errors to client-error responses. */ +function uploadSingleFile(req: Request, res: Response, next: (err?: unknown) => void): void { + upload.single("file")(req, res, (err: unknown) => { + if (err instanceof multer.MulterError) { + // Client errors (oversized file, unexpected field) — not server 500s + const status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400; + res.status(status).json({ error: err.message }); + return; + } + next(err); + }); +} + // POST /api/attachments -attachmentsRouter.post("/", upload.single("file"), asyncHandler(async (req: Request, res: Response) => { +attachmentsRouter.post("/", uploadSingleFile, asyncHandler(async (req: Request, res: Response) => { const file = req.file; if (!file) { badRequest(res, "No file uploaded"); diff --git a/src/apps/kanban/src/routes/issues.ts b/src/apps/kanban/src/routes/issues.ts index 617a394..681f5ea 100644 --- a/src/apps/kanban/src/routes/issues.ts +++ b/src/apps/kanban/src/routes/issues.ts @@ -40,7 +40,13 @@ function mapIssue(row: IssueLikeRow | undefined): Record | unde // GET /api/issues issuesRouter.get("/", asyncHandler(async (req: Request, res: Response) => { const qp = listIssuesQuerySchema.safeParse(req.query); - const { featureId, columnId, priority, type } = qp.success ? qp.data : {}; + if (!qp.success) { + // Silently dropping the filters here would return every issue of every + // feature for a malformed query — fail loudly like the other handlers. + badRequest(res, qp.error.issues[0]?.message ?? "Invalid query"); + return; + } + const { featureId, columnId, priority, type } = qp.data; const db = getDb(req.projectId); const conditions: string[] = []; @@ -222,8 +228,21 @@ issuesRouter.post("/reorder", asyncHandler(async (req: Request, res: Response) = const txn = db.transaction(() => { // Verify issue exists BEFORE shifting - const existing = db.prepare(`SELECT * FROM "Issue" WHERE "id" = ?`).get(issueId); - if (!existing) return null; + const existing = db.prepare(`SELECT * FROM "Issue" WHERE "id" = ?`).get(issueId) as + | { projectId: string } + | undefined; + if (!existing) return { error: "issue" as const }; + + // The target column must exist and belong to the issue's feature — + // otherwise the issue's columnId points into another board while its + // projectId stays put, making it invisible on every board (same check + // PATCH /:id performs). + const col = db.prepare(`SELECT "projectId" FROM "Column" WHERE "id" = ?`).get(newColumnId) as + | { projectId: string } + | undefined; + if (!col || col.projectId !== existing.projectId) { + return { error: "column" as const }; + } // Shift existing issues in target column db.prepare( @@ -235,14 +254,18 @@ issuesRouter.post("/reorder", asyncHandler(async (req: Request, res: Response) = `UPDATE "Issue" SET "columnId" = ?, "order" = ?, "updatedAt" = ? WHERE "id" = ?` ).run(newColumnId, newOrder, now, issueId); - return existing; + return { error: null }; }); const result = txn(); - if (!result) { + if (result.error === "issue") { notFound(res, "Issue not found"); return; } + if (result.error === "column") { + badRequest(res, "Provided newColumnId does not belong to the issue's feature."); + return; + } const row = db.prepare(`SELECT * FROM "Issue" WHERE "id" = ?`).get(issueId) as JsonRow | undefined; diff --git a/src/apps/log/src/safe-log-path.ts b/src/apps/log/src/safe-log-path.ts index 21ef0de..0c36eaf 100644 --- a/src/apps/log/src/safe-log-path.ts +++ b/src/apps/log/src/safe-log-path.ts @@ -16,7 +16,11 @@ export function safeLogPath(targetPath: string): string { const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(LOG_ROOT, targetPath); - if (!resolved.startsWith(LOG_ROOT + path.sep)) { + // Windows paths are case-insensitive — fold both sides so a valid absolute + // path with a different drive-letter/directory case is not rejected. The + // containment check itself stays (fails closed on real traversal). + const fold = (p: string) => (process.platform === 'win32' ? p.toLowerCase() : p); + if (!fold(resolved).startsWith(fold(LOG_ROOT + path.sep))) { throw new Error('Path traversal denied'); } const ext = path.extname(resolved).toLowerCase(); diff --git a/src/apps/log/src/services/file-tailer.ts b/src/apps/log/src/services/file-tailer.ts index 707b6a2..daf71cf 100644 --- a/src/apps/log/src/services/file-tailer.ts +++ b/src/apps/log/src/services/file-tailer.ts @@ -93,23 +93,31 @@ export class FileTailer { return; } + // Reserve the slot before the awaits below — concurrent chokidar `add` + // events (initial directory scan) would otherwise all pass the cap check + // before any of them increments the count. + this.watchedCount++; + let reserved = true; + const release = () => { if (reserved) { reserved = false; this.watchedCount--; } }; + try { const stat = await fsp.stat(filePath); if (stat.size > MAX_FILE_SIZE) { console.log(`[file-tailer] Skipping large file: ${filePath} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`); + release(); return; } // Peek at first line — skip if it looks like a DevGlide JSONL file if (stat.size > 0 && (await this.isDevGlideJsonl(filePath))) { console.log(`[file-tailer] Skipping DevGlide JSONL file: ${filePath}`); + release(); return; } // Start from end of file — only tail new content this.offsets.set(filePath, stat.size); this.partials.set(filePath, ""); - this.watchedCount++; const sessionId = fileSessionId(filePath); const targetPath = filetailTargetPath(this.filetailDir!, filePath); @@ -130,6 +138,7 @@ export class FileTailer { console.log(`[file-tailer] Tailing: ${filePath}`); } catch (err) { + release(); console.error(`[file-tailer] Failed to add ${filePath}:`, (err as Error).message); } } @@ -236,6 +245,7 @@ export class FileTailer { if (this.offsets.has(filePath)) { this.offsets.delete(filePath); this.partials.delete(filePath); + this.changeQueues.delete(filePath); this.watchedCount--; console.log(`[file-tailer] Removed: ${filePath}`); } diff --git a/src/apps/prompts/services/prompt-store.ts b/src/apps/prompts/services/prompt-store.ts index d5ed095..669b1d3 100644 --- a/src/apps/prompts/services/prompt-store.ts +++ b/src/apps/prompts/services/prompt-store.ts @@ -114,39 +114,6 @@ export class PromptStore extends JsonFileStore { }); } - /** - * Locate the scope (and owning project) an existing entity lives in. - * Unlike resolveExistingScope, this also searches all project dirs when - * no active project is set (stdio MCP mode) so updates write in place - * instead of creating a shadowed global duplicate. - */ - private async locateExisting(id: string): Promise<{ scope: 'project' | 'global'; projectId?: string } | undefined> { - const scope = await this.resolveExistingScope(id); - if (scope) return { scope, projectId: scope === 'project' ? getActiveProject()?.id : undefined }; - - const featureName = path.basename(this.baseDir); - // New project dirs: ~/.devglide/projects/{projectId}/{feature}/ - let projectIds: string[] = []; - try { projectIds = await fs.readdir(PROJECTS_DIR); } catch { /* none */ } - for (const projectId of projectIds) { - try { - await fs.access(path.join(PROJECTS_DIR, projectId, featureName, `${id}.json`)); - return { scope: 'project', projectId }; - } catch { /* keep looking */ } - } - // Legacy project dirs: baseDir/{projectId}/ - let names: string[] = []; - try { names = await fs.readdir(this.baseDir); } catch { /* none */ } - for (const name of names) { - if (name.endsWith('.json')) continue; - try { - await fs.access(path.join(this.baseDir, name, `${id}.json`)); - return { scope: 'project', projectId: name }; - } catch { /* keep looking */ } - } - return undefined; - } - /** * Atomically fetch, merge, and write — eliminates TOCTOU race in callers. * undefined = keep existing, null = clear field, value = update. diff --git a/src/apps/test/src/services/scenario-broadcaster.ts b/src/apps/test/src/services/scenario-broadcaster.ts index d7bd7e2..7d820ce 100644 --- a/src/apps/test/src/services/scenario-broadcaster.ts +++ b/src/apps/test/src/services/scenario-broadcaster.ts @@ -45,16 +45,23 @@ export class ScenarioBroadcaster { /** * Broadcast a scenario payload to all SSE clients for a target key. - * Returns true if at least one connected client received the payload. + * Returns true only if the payload was written to at least one client whose + * socket is still writable — the caller drops the queued copy on `true`, so + * counting a dead-but-not-yet-removed connection would lose the scenario. */ broadcast(key: string, scenario: unknown): boolean { const clients = this.clients.get(key); if (!clients || clients.size === 0) return false; const payload = `data: ${JSON.stringify(scenario)}\n\n`; + let delivered = false; for (const res of clients) { - try { res.write(payload); } catch { /* cleaned up on close */ } + if (res.writableEnded || res.destroyed) continue; + try { + res.write(payload); + delivered = true; + } catch { /* cleaned up on close */ } } - return true; + return delivered; } shutdown(): void { diff --git a/src/apps/test/src/services/scenario-manager.ts b/src/apps/test/src/services/scenario-manager.ts index 38cf402..50762fb 100644 --- a/src/apps/test/src/services/scenario-manager.ts +++ b/src/apps/test/src/services/scenario-manager.ts @@ -102,11 +102,16 @@ export class ScenarioManager { listResults(projectPath?: string | null): ScenarioResult[] { if (!projectPath) return []; const all = Array.from(this.results.values()); - const prefix = projectPath; const basename = path.basename(projectPath); return all.filter((r) => { if (!r.target) return false; - return r.target.startsWith(prefix) || r.target === basename; + // Path-boundary-aware match: a bare prefix would leak results from a + // sibling project whose path merely starts with this one (app vs app-v2). + return ( + r.target === projectPath || + r.target.startsWith(projectPath + path.sep) || + r.target === basename + ); }); } @@ -249,7 +254,9 @@ export class ScenarioManager { if (!projectPath) return 0; let count = 0; for (const [target, scenarios] of this.scenariosByTarget) { - if (target.startsWith(projectPath)) count += scenarios.length; + if (target === projectPath || target.startsWith(projectPath + path.sep)) { + count += scenarios.length; + } } return count; } diff --git a/src/apps/vocabulary/services/vocabulary-store.ts b/src/apps/vocabulary/services/vocabulary-store.ts index 880f5fb..23243ca 100644 --- a/src/apps/vocabulary/services/vocabulary-store.ts +++ b/src/apps/vocabulary/services/vocabulary-store.ts @@ -1,8 +1,6 @@ -import fs from 'fs/promises'; -import path from 'path'; import type { VocabularyEntry, VocabularyEntrySummary } from '../types.js'; import { getActiveProject } from '../../../project-context.js'; -import { PROJECTS_DIR, VOCABULARY_DIR } from '../../../packages/paths.js'; +import { VOCABULARY_DIR } from '../../../packages/paths.js'; import { JsonFileStore } from '../../../packages/json-file-store.js'; /** @@ -103,42 +101,41 @@ export class VocabularyStore extends JsonFileStore { } scope = scope ?? (getActiveProject() ? 'project' : 'global'); + // Stamp the owning project on project-scoped entries — without it the + // getCompiledContext(projectId) filter cannot exclude other projects' + // terms (they persist with projectId undefined). + entry.projectId = scope === 'project' && scopeProjectId ? scopeProjectId : undefined; + await this.writeEntity(entry, scope, scopeProjectId); return entry; }); } /** - * Locate the scope (and owning project) an existing entity lives in. - * Unlike resolveExistingScope, this also searches all project dirs when - * no active project is set (stdio MCP mode) so updates write in place - * instead of creating a shadowed global duplicate. + * Atomically fetch, merge, and write — eliminates the get→merge→save TOCTOU + * race in callers. undefined = keep existing, null = clear field. */ - private async locateExisting(id: string): Promise<{ scope: 'project' | 'global'; projectId?: string } | undefined> { - const scope = await this.resolveExistingScope(id); - if (scope) return { scope, projectId: scope === 'project' ? getActiveProject()?.id : undefined }; - - const featureName = path.basename(this.baseDir); - // New project dirs: ~/.devglide/projects/{projectId}/{feature}/ - let projectIds: string[] = []; - try { projectIds = await fs.readdir(PROJECTS_DIR); } catch { /* none */ } - for (const projectId of projectIds) { - try { - await fs.access(path.join(PROJECTS_DIR, projectId, featureName, `${id}.json`)); - return { scope: 'project', projectId }; - } catch { /* keep looking */ } - } - // Legacy project dirs: baseDir/{projectId}/ - let names: string[] = []; - try { names = await fs.readdir(this.baseDir); } catch { /* none */ } - for (const name of names) { - if (name.endsWith('.json')) continue; - try { - await fs.access(path.join(this.baseDir, name, `${id}.json`)); - return { scope: 'project', projectId: name }; - } catch { /* keep looking */ } - } - return undefined; + async update( + id: string, + fields: { [K in keyof Omit]?: VocabularyEntry[K] | null }, + ): Promise { + return this.withLock(id, async () => { + const existing = await this.get(id); + if (!existing) return null; + + const merged: VocabularyEntry = { ...existing, updatedAt: new Date().toISOString() }; + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue; + (merged as unknown as Record)[key] = value === null ? undefined : value; + } + + const located = await this.locateExisting(id); + const scope = located?.scope ?? (getActiveProject() ? 'project' : 'global'); + const scopeProjectId = located?.projectId ?? getActiveProject()?.id; + merged.projectId = scope === 'project' && scopeProjectId ? scopeProjectId : undefined; + await this.writeEntity(merged, scope, scopeProjectId); + return merged; + }); } async getCompiledContext(projectId?: string): Promise { diff --git a/src/apps/vocabulary/src/mcp.ts b/src/apps/vocabulary/src/mcp.ts index 0eb3d53..5b6bb03 100644 --- a/src/apps/vocabulary/src/mcp.ts +++ b/src/apps/vocabulary/src/mcp.ts @@ -124,32 +124,30 @@ export function createVocabularyMcpServer(): McpServer { tags: z.string().optional().describe('JSON array of tag strings'), }, async ({ id, term, definition, aliases, category, tags }) => { - const existing = await store.get(id); - if (!existing) return errorResult('Entry not found'); - - let parsedAliases: string[] | undefined = existing.aliases; + let parsedAliases: string[] | undefined; if (aliases) { const parsed = parseStringArray(aliases); if (!parsed) return errorResult('aliases must be a JSON array of strings'); parsedAliases = parsed; } - let parsedTags: string[] = existing.tags; + let parsedTags: string[] | undefined; if (tags) { const parsed = parseStringArray(tags); if (!parsed) return errorResult('tags must be a JSON array of strings'); parsedTags = parsed; } - const updated = await store.save({ - id, - term: term ?? existing.term, - definition: definition ?? existing.definition, + // Atomic read-merge-write inside the store lock — a separate + // get()+save() here loses concurrent field updates. + const updated = await store.update(id, { + term, + definition, aliases: parsedAliases, - category: category ?? existing.category, + category, tags: parsedTags, - projectId: existing.projectId, }); + if (!updated) return errorResult('Entry not found'); return jsonResult(updated); }, diff --git a/src/apps/voice/src/mcp.ts b/src/apps/voice/src/mcp.ts index a38d370..a2255ca 100644 --- a/src/apps/voice/src/mcp.ts +++ b/src/apps/voice/src/mcp.ts @@ -4,6 +4,7 @@ import { transcribe } from "./transcribe.js"; import { stats } from "./services/stats.js"; import { historyStore } from "./services/history-store.js"; import { mimeFromFilename } from "./utils/mime.js"; +import { isValidLanguage, validateAudioBase64 } from "./utils/validate.js"; import { configStore } from "./services/config-store.js"; import { speak, stop as ttsStop } from "./services/tts.js"; @@ -76,6 +77,14 @@ export function createVoiceMcpServer() { async ({ audioBase64, filename, language, prompt, mode }) => { const startTime = Date.now(); try { + // Same guards as the REST route: size/charset for the payload, and a + // strict language tag — the local whisper provider passes language + // into a shell command. + const b64Error = validateAudioBase64(audioBase64); + if (b64Error) throw new Error(b64Error); + if (language !== undefined && !isValidLanguage(language)) { + throw new Error(`Invalid language value "${language}". Use BCP 47 code (e.g. "en", "en-US") or "auto".`); + } const name = filename || "audio.webm"; const buffer = Buffer.from(audioBase64, "base64"); const file = new File([buffer], name, { diff --git a/src/apps/voice/src/providers/local-whisper.bundle.test.ts b/src/apps/voice/src/providers/local-whisper.bundle.test.ts new file mode 100644 index 0000000..273d02c --- /dev/null +++ b/src/apps/voice/src/providers/local-whisper.bundle.test.ts @@ -0,0 +1,25 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// dist/mcp/voice.mjs must treat nodejs-whisper as an external runtime +// dependency. Bundling it inlines CJS modules that reference __dirname +// (ReferenceError in ESM scope) and rebases WHISPER_CPP_PATH onto +// dist/cpp/whisper.cpp, which does not exist — the local provider then +// fails with a misleading "install nodejs-whisper" error. +const repoRoot = resolve(__dirname, '../../../../..'); +const bundlePath = join(repoRoot, 'dist', 'mcp', 'voice.mjs'); + +describe('voice MCP bundle', () => { + it.skipIf(!existsSync(bundlePath))('does not inline nodejs-whisper', () => { + const bundle = readFileSync(bundlePath, 'utf8'); + expect(bundle).not.toContain('WHISPER_CPP_PATH'); + expect(bundle).not.toContain('nodejs-whisper/dist/constants.js'); + }); + + it.skipIf(!existsSync(bundlePath))('can resolve nodejs-whisper from the bundle location', () => { + const require_ = createRequire(bundlePath); + expect(() => require_.resolve('nodejs-whisper/package.json')).not.toThrow(); + }); +}); diff --git a/src/apps/voice/src/providers/local-whisper.ts b/src/apps/voice/src/providers/local-whisper.ts index c633508..aaf3ffc 100644 --- a/src/apps/voice/src/providers/local-whisper.ts +++ b/src/apps/voice/src/providers/local-whisper.ts @@ -11,6 +11,7 @@ import type { TranscribeOptions, TranscriptionResult, } from "./types.js"; +import { isValidLanguage } from "../utils/validate.js"; interface LocalWhisperSegment { speech?: string; @@ -75,6 +76,7 @@ const BUILD_TOOLS_HINT = " Windows: winget install Kitware.CMake\n" + " winget install Microsoft.VisualStudio.2022.BuildTools --override \"--add Microsoft.VisualStudio.Workload.VCTools\"\n" + " macOS: xcode-select --install\n" + + " brew install cmake (Command Line Tools do NOT include CMake)\n" + " Linux: sudo apt install build-essential cmake\n" + "\n" + "Step 2 — Compile whisper.cpp (from project root):\n" + @@ -212,22 +214,44 @@ function extractZip(zipPath: string, destDir: string, files: string[]): void { }); } +/** Check whether cmake is available on PATH. */ +function cmakeAvailable(): boolean { + try { + execSync("cmake --version", { stdio: "pipe", timeout: 10_000 }); + return true; + } catch { + return false; + } +} + /** Attempt to build whisper.cpp from source using CMake. */ function buildFromSource(whisperCppPath: string): boolean { + if (!cmakeAvailable()) { + console.warn( + "[devglide-voice] CMake not found on PATH — cannot build whisper.cpp.\n" + + " macOS: brew install cmake (xcode-select --install alone is not enough)\n" + + " Windows: winget install Kitware.CMake\n" + + " Linux: sudo apt install cmake" + ); + return false; + } + try { console.log("[devglide-voice] Building whisper.cpp from source…"); const cmakeCache = join(whisperCppPath, "build", "CMakeCache.txt"); if (!existsSync(cmakeCache)) { console.log("[devglide-voice] Configuring CMake build…"); - execSync("cmake -B build", { cwd: whisperCppPath, stdio: "pipe", timeout: 60_000 }); + execSync("cmake -B build", { cwd: whisperCppPath, stdio: "pipe", timeout: 180_000 }); } - console.log("[devglide-voice] Compiling (this may take a few minutes)…"); + // First build compiles all of ggml (incl. Metal on macOS) — routinely + // exceeds 5 minutes on laptops, so allow 15. + console.log("[devglide-voice] Compiling (this may take several minutes)…"); execSync("cmake --build build --config Release", { cwd: whisperCppPath, stdio: "pipe", - timeout: 300_000, + timeout: 900_000, }); if (whisperCliExists(whisperCppPath)) { @@ -337,12 +361,21 @@ export class LocalWhisperProvider implements TranscriptionProvider { let nodeWhisper: LocalWhisperFn; try { nodeWhisper = await loadLocalWhisper(); - } catch { + } catch (err: unknown) { + const cause = err instanceof Error ? err.message : String(err); throw new Error( - "Local whisper provider requires the 'nodejs-whisper' package. Install it with: pnpm add nodejs-whisper" + `Failed to load the 'nodejs-whisper' package: ${cause}\n` + + "If the package is missing, install it with: pnpm add nodejs-whisper" ); } + // Defense in depth: nodejs-whisper interpolates the language flag into a + // shell command without escaping — never let a non-tag value through even + // if an entry point forgot to validate. + if (options.language !== undefined && !isValidLanguage(options.language)) { + throw new Error(`Invalid language value ${JSON.stringify(options.language)}`); + } + // Verify FFmpeg is available before attempting transcription const ffmpeg = checkFfmpeg(); if (!ffmpeg.ok) { diff --git a/src/apps/voice/src/routes/transcribe.ts b/src/apps/voice/src/routes/transcribe.ts index 2929967..7b84f17 100644 --- a/src/apps/voice/src/routes/transcribe.ts +++ b/src/apps/voice/src/routes/transcribe.ts @@ -5,6 +5,7 @@ import { stats } from "../services/stats.js"; import { mimeFromFilename } from "../utils/mime.js"; import { transcribe } from "../transcribe.js"; import { errorMessage } from "../../../../packages/error-middleware.js"; +import { isValidLanguage, validateAudioBase64 } from "../utils/validate.js"; export const transcribeRouter: Router = Router(); @@ -22,33 +23,16 @@ export async function handleTranscribe(req: Request, res: Response) { return; } - // Reject payloads larger than 25MB of base64 (~18.75MB decoded) - if (audioBase64.length > 25 * 1024 * 1024) { - res.status(413).json({ error: "audioBase64 exceeds maximum size (25MB)" }); + const b64Error = validateAudioBase64(audioBase64); + if (b64Error) { + res.status(b64Error.includes("maximum size") ? 413 : 400).json({ error: b64Error }); return; } - // Validate base64: check length is valid and content uses only base64 chars. - // Use a chunked regex to avoid catastrophic backtracking on large strings. - const b64Len = audioBase64.length; - if (b64Len % 4 !== 0) { - res.status(400).json({ error: "audioBase64 is not valid base64" }); - return; - } - const b64ChunkRe = /^[A-Za-z0-9+/]*$/; - const CHUNK = 64 * 1024; // validate in 64KB chunks - let b64Valid = true; - for (let off = 0; off < b64Len; off += CHUNK) { - const slice = audioBase64.slice(off, Math.min(off + CHUNK, b64Len)); - // Allow trailing '=' only in the final chunk - if (off + CHUNK >= b64Len) { - if (!/^[A-Za-z0-9+/]*={0,2}$/.test(slice)) { b64Valid = false; break; } - } else { - if (!b64ChunkRe.test(slice)) { b64Valid = false; break; } - } - } - if (!b64Valid) { - res.status(400).json({ error: "audioBase64 is not valid base64" }); + // The local whisper provider passes language into a shell command — reject + // anything that is not a plain BCP 47 tag before it reaches a provider. + if (language !== undefined && !isValidLanguage(language)) { + res.status(400).json({ error: `Invalid language value "${language}". Use BCP 47 code (e.g. "en", "en-US") or "auto".` }); return; } diff --git a/src/apps/voice/src/services/tts.ts b/src/apps/voice/src/services/tts.ts index 738ac46..4781f36 100644 --- a/src/apps/voice/src/services/tts.ts +++ b/src/apps/voice/src/services/tts.ts @@ -13,8 +13,9 @@ * since Linux audio binaries aren't typically available in WSL. */ -import { unlinkSync, readFileSync, existsSync } from "fs"; -import { join } from "path"; +import { unlinkSync, readFileSync, existsSync, mkdirSync, rmdirSync } from "fs"; +import { join, dirname } from "path"; +import { randomBytes } from "crypto"; import { tmpdir } from "os"; import { platform } from "os"; import { spawn, execSync, spawnSync, type ChildProcess } from "child_process"; @@ -31,6 +32,12 @@ let _sessionId = 0; function safeUnlink(path: string | null): void { if (!path) return; try { unlinkSync(path); } catch { /* already gone */ } + // Each generation gets its own subdirectory under devglide-tts — remove it + // once its file is gone (rmdirSync only succeeds when empty). + try { + const dir = dirname(path); + if (dirname(dir).endsWith("devglide-tts")) rmdirSync(dir); + } catch { /* not empty or already gone */ } } /** Track a temp file as belonging to a TTS session. */ @@ -305,8 +312,12 @@ async function generateEdgeTts( const tts = new _MsEdgeTTS(); await tts.setMetadata(voice, _OUTPUT_FORMAT!.AUDIO_24KHZ_48KBITRATE_MONO_MP3); - // Always write to native temp — playMp3() handles Windows path conversion - const outDir = tmpdir(); + // msedge-tts writes a CONSTANT filename (audio.mp3) into the directory it + // is given. Chunked playback generates chunk i+1 while chunk i plays, so + // every generation must get its own directory or it overwrites the file + // currently being played. Native temp — playMp3() handles Windows paths. + const outDir = join(tmpdir(), "devglide-tts", randomBytes(6).toString("hex")); + mkdirSync(outDir, { recursive: true }); // Race against a timeout — msedge-tts can hang on bad config. // Scale timeout with text length: 15s base + 1s per 40 chars (≈ per sentence). @@ -606,14 +617,17 @@ export async function speak(text: string): Promise { _activeProcess = playMp3(playPath); console.error(`[voice:tts] playMp3 started, process=${_activeProcess?.pid ?? 'null'}`); if (_activeProcess) { + // Safety net: clean up if the exit event never fires. Cleared on exit + // so a long MP3 (>2 min, e.g. the single-shot fallback for long text) + // is not deleted mid-playback. + const safetyTimer = setTimeout(() => { cleanupSessionFiles(mySession); }, 120_000); + safetyTimer.unref?.(); _activeProcess.on("exit", (code) => { console.error(`[voice:tts] playback exited code=${code}`); + clearTimeout(safetyTimer); cleanupSessionFiles(mySession); _activeProcess = null; }); - // Safety: clean up this session's files after 2 minutes even if exit - // never fires (no-op if they were already cleaned up) - setTimeout(() => { cleanupSessionFiles(mySession); }, 120_000); } else { // Playback didn't start — clean up immediately cleanupSessionFiles(mySession); diff --git a/src/apps/voice/src/utils/validate.ts b/src/apps/voice/src/utils/validate.ts new file mode 100644 index 0000000..75d821a --- /dev/null +++ b/src/apps/voice/src/utils/validate.ts @@ -0,0 +1,40 @@ +/** Shared input validation for the transcription entry points (REST + MCP). */ + +/** + * BCP 47 language tag (e.g. "en", "en-US") or "auto". + * The local whisper provider interpolates the language into a shell command + * (nodejs-whisper does not escape it), so this must stay strict. + */ +export function isValidLanguage(language: string): boolean { + return language === "auto" || /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(language); +} + +/** Max accepted base64 payload (~18.75MB decoded). */ +export const MAX_AUDIO_BASE64_LENGTH = 25 * 1024 * 1024; + +/** + * Validate a base64 audio payload: size cap, length multiple of 4, and + * charset. Chunked regex to avoid catastrophic backtracking on large strings. + * Returns an error message, or null when valid. + */ +export function validateAudioBase64(audioBase64: string): string | null { + if (audioBase64.length > MAX_AUDIO_BASE64_LENGTH) { + return "audioBase64 exceeds maximum size (25MB)"; + } + const b64Len = audioBase64.length; + if (b64Len % 4 !== 0) { + return "audioBase64 is not valid base64"; + } + const b64ChunkRe = /^[A-Za-z0-9+/]*$/; + const CHUNK = 64 * 1024; + for (let off = 0; off < b64Len; off += CHUNK) { + const slice = audioBase64.slice(off, Math.min(off + CHUNK, b64Len)); + // Allow trailing '=' only in the final chunk + if (off + CHUNK >= b64Len) { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(slice)) return "audioBase64 is not valid base64"; + } else { + if (!b64ChunkRe.test(slice)) return "audioBase64 is not valid base64"; + } + } + return null; +} diff --git a/src/apps/workflow/engine/executors/decision-executor.ts b/src/apps/workflow/engine/executors/decision-executor.ts index 519a9a7..eba3a0b 100644 --- a/src/apps/workflow/engine/executors/decision-executor.ts +++ b/src/apps/workflow/engine/executors/decision-executor.ts @@ -76,8 +76,18 @@ export const decisionExecutor: ExecutorFunction = async ( } if (!selectedPort && cfg.ports.length > 0) { - const defaultPort = cfg.ports.find((p) => !p.condition) ?? cfg.ports[cfg.ports.length - 1]; - selectedPort = defaultPort.id; + const defaultPort = cfg.ports.find((p) => !p.condition); + if (defaultPort) { + selectedPort = defaultPort.id; + } else { + // No port matched and every port has a condition — executing an + // arbitrary branch whose condition just evaluated false is worse + // than failing (e.g. exit code 2 must not route down the '1' port). + return { + status: 'failed', + error: 'Decision matched no port and no unconditional default port exists', + }; + } } return { diff --git a/src/apps/workflow/engine/executors/llm-executor.ts b/src/apps/workflow/engine/executors/llm-executor.ts index 84753b1..3c6ea0c 100644 --- a/src/apps/workflow/engine/executors/llm-executor.ts +++ b/src/apps/workflow/engine/executors/llm-executor.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; -import path from 'path'; import OpenAI from 'openai'; import type { ExecutorFunction, ExecutorResult, NodeConfig, ExecutionContext, SSEEmitter, LlmConfig } from '../../types.js'; +import { safePath } from './file-executor.js'; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -22,10 +22,10 @@ export const llmExecutor: ExecutorFunction = async ( return { status: 'failed', error: 'promptFile is required when promptSource is file' }; } const base = _context.project?.path ?? process.cwd(); - const filePath = path.resolve(base, cfg.promptFile.replace(/^\/+/, '')); - if (!filePath.startsWith(base + path.sep) && filePath !== base) { - return { status: 'failed', error: 'Path traversal denied' }; - } + // safePath realpath-resolves — a symlink inside the project pointing + // outside it (e.g. at ~/.ssh) must not be readable as a prompt; the + // lexical prefix check alone does not catch that. + const filePath = await safePath(cfg.promptFile, base); prompt = await fs.readFile(filePath, 'utf-8'); } else { if (!cfg.prompt) { diff --git a/src/apps/workflow/engine/executors/sub-workflow-executor.ts b/src/apps/workflow/engine/executors/sub-workflow-executor.ts index 5d78598..7551f70 100644 --- a/src/apps/workflow/engine/executors/sub-workflow-executor.ts +++ b/src/apps/workflow/engine/executors/sub-workflow-executor.ts @@ -47,8 +47,11 @@ export const subWorkflowExecutor: ExecutorFunction = async ( emit(event); }; - // Pass the parent context as cancel token so cancellation propagates to sub-workflows - const result = await runWorkflow(workflow, childEmit, undefined, childVariables, context.services, context); + // Pass the run's ROOT cancel token — the parent's own `cancelled` flag is + // only mirrored at its loop head, which is suspended while this node + // awaits, so it never flips mid-sub-workflow. The root token is the one + // RunManager actually sets on cancel/shutdown. + const result = await runWorkflow(workflow, childEmit, undefined, childVariables, context.services, context.cancelToken ?? context); const outputVars: Record = {}; if (cfg.outputMappings && result.variables) { diff --git a/src/apps/workflow/engine/expression-evaluator.ts b/src/apps/workflow/engine/expression-evaluator.ts index d96c34a..00c6c6f 100644 --- a/src/apps/workflow/engine/expression-evaluator.ts +++ b/src/apps/workflow/engine/expression-evaluator.ts @@ -26,11 +26,28 @@ function evaluateExpression(expr: string): boolean { return evaluateComparison(expr); } +/** + * Index of `op` in `expr` outside quoted regions — a plain indexOf misparses + * expressions whose interpolated operand values contain operator characters + * (e.g. left operand "x != y" shifting the split point). + */ +function indexOfOutsideQuotes(expr: string, op: string): number { + let inSingle = false; + let inDouble = false; + for (let i = 0; i <= expr.length - op.length; i++) { + const ch = expr[i]; + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (!inSingle && !inDouble && expr.startsWith(op, i)) return i; + } + return -1; +} + function evaluateComparison(expr: string): boolean { const operators = ['!=', '==', '>=', '<=', '>', '<', ' contains ', ' matches '] as const; for (const op of operators) { - const idx = expr.indexOf(op); + const idx = indexOfOutsideQuotes(expr, op); if (idx === -1) continue; const left = expr.slice(0, idx).trim(); diff --git a/src/apps/workflow/engine/graph-runner.ts b/src/apps/workflow/engine/graph-runner.ts index d61fe69..77d2b2a 100644 --- a/src/apps/workflow/engine/graph-runner.ts +++ b/src/apps/workflow/engine/graph-runner.ts @@ -55,9 +55,14 @@ export async function runWorkflow( status: 'running', startedAt: new Date().toISOString(), cancelled: false, + loopStack: [], project: projectSnapshot, services: services ?? {}, }; + // Carry the root cancel token so sub-workflow executors can hand it to + // their children — the parent's own `cancelled` flag is only mirrored at + // the loop head, which is suspended while a sub-workflow node runs. + context.cancelToken = cancelToken ?? { cancelled: false }; for (const v of workflow.variables) { if (v.defaultValue !== undefined) { @@ -132,7 +137,14 @@ export async function runWorkflow( const existingState = context.nodeStates.get(nodeId); if (existingState && (existingState.status === 'passed' || existingState.status === 'failed')) { if (node.type !== 'loop') { - enqueueSuccessors(node, outgoing, context, queue, queueSet, emit); + // A failed node must forward its ERROR edges, not its normal + // successors — otherwise a stall-failed join re-dequeued later fires + // its downstream branch in a run already reported as failed. + if (existingState.status === 'failed') { + handleFailure(node, outgoing, context, queue, queueSet, emit); + } else { + enqueueSuccessors(node, outgoing, context, queue, queueSet, emit); + } continue; } } @@ -152,6 +164,16 @@ export async function runWorkflow( continue; } + // The node is actually executing — its waiting is over, so its stall + // budget resets (a legitimately-waiting join otherwise burns budget once + // per interleaved node execution and false-positives near loops). + requeueCounts.delete(nodeId); + + // Resolve {{loop.*}} against the innermost active loop whose body + // contains this node — a single shared slot is clobbered by + // nested/parallel loops. + context.loopContext = loopContextForNode(nodeId, context); + // Provide predecessor IDs so decision executor can find actual predecessors if (node.config.nodeType === 'decision') { const predecessorIds = (incoming.get(nodeId) ?? []).map((e) => e.source); @@ -173,7 +195,7 @@ export async function runWorkflow( // route a later (or failed) decision down the wrong branch. context.variables.delete('__decision_port'); } else if (node.config.nodeType === 'loop') { - handleLoop(node, outgoing, incoming, context, queue, queueSet, emit, nodeMap, loopCounters, activeLoopBodies); + handleLoop(node, outgoing, incoming, context, queue, queueSet, emit, nodeMap, loopCounters, activeLoopBodies, requeueCounts); } else if (result.status === 'failed') { handleFailure(node, outgoing, context, queue, queueSet, emit); } else { @@ -207,20 +229,45 @@ function allPredecessorsComplete( }); } -/** An edge out of a passed decision fires only for the selected port. */ +/** + * An edge that can no longer fire, given its source's terminal state: + * decision edges for unselected ports, conditional edges whose condition is + * false, and error edges out of a node that passed. Nodes reachable only + * through such edges count as skipped so joins do not stall forever. + */ function isEdgeUntaken( edge: WorkflowEdge, nodeMap: Map, context: ExecutionContext, ): boolean { + const srcState = context.nodeStates.get(edge.source); + const srcTerminal = srcState && (srcState.status === 'passed' || srcState.status === 'failed'); + + // Error edges fire only on failure — after a pass they are dead. + if (srcTerminal && edge.sourcePort === 'error' && srcState.status === 'passed') return true; + + // Conditional edge whose condition evaluated false never fires. + if (srcTerminal && edge.condition && !evaluateEdgeCondition(edge, context)) return true; + const srcNode = nodeMap.get(edge.source); if (srcNode?.config.nodeType !== 'decision') return false; - const srcState = context.nodeStates.get(edge.source); if (srcState?.status !== 'passed') return false; // The decision executor stores the selected port in the node state output return typeof srcState.output === 'string' && edge.sourcePort !== srcState.output; } +/** + * Innermost active loop frame whose body contains `nodeId` (frames are + * pushed outermost-first, so scan from the end). + */ +function loopContextForNode(nodeId: string, context: ExecutionContext) { + const stack = context.loopStack ?? []; + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].bodyIds.has(nodeId)) return stack[i].ctx; + } + return undefined; +} + /** * A node is skipped when it can never run: it has no state and every incoming * edge is either on an untaken decision branch or comes from a skipped node. @@ -359,6 +406,7 @@ function handleLoop( nodeMap: Map, loopCounters: Map, activeLoopBodies: Map>, + requeueCounts: Map, ): void { const config = node.config as LoopConfig; const maxIter = config.maxIterations ?? MAX_LOOP_ITERATIONS; @@ -371,18 +419,43 @@ function handleLoop( const shouldContinue = evaluateLoopCondition(config, counter, context); if (shouldContinue && counter < maxIter) { - context.loopContext = { + const loopCtx = { index: counter, item: getLoopItem(config, counter, context), collection: getLoopCollection(config, context), }; + context.loopContext = loopCtx; + // Expose the current item under the user-configured variable name — the + // builder UI offers `itemVariable` and body nodes reference it via + // {{variables.}}. + if (config.loopType === 'for-each' && config.itemVariable) { + context.variables.set(config.itemVariable, loopCtx.item); + } emit({ type: 'loop_iteration', nodeId: node.id, index: counter }); loopCounters.set(node.id, counter + 1); const bodyNodeIds = collectBodyNodes(bodyEdges, outgoing, doneEdges.map((e) => e.target)); + + // Register/refresh this loop's frame so body nodes resolve {{loop.*}} + // against the right loop even when loops nest or run in parallel. + const stack = (context.loopStack ??= []); + const frame = stack.find((f) => f.loopId === node.id); + if (frame) { + frame.ctx = loopCtx; + frame.bodyIds = bodyNodeIds; + } else { + stack.push({ loopId: node.id, bodyIds: bodyNodeIds, ctx: loopCtx }); + } + for (const id of bodyNodeIds) { context.nodeStates.delete(id); + // Reset per-node bookkeeping too: an inner loop must restart from + // iteration 0 on each outer iteration, and body nodes get a fresh + // stall budget. + loopCounters.delete(id); + activeLoopBodies.delete(id); + requeueCounts.delete(id); } for (const edge of bodyEdges) { @@ -409,6 +482,11 @@ function handleLoop( } else { context.loopContext = undefined; activeLoopBodies.delete(node.id); + // Loop finished — drop its frame so nodes after it resolve {{loop.*}} + // against the enclosing loop (if any) instead of this one. + if (context.loopStack) { + context.loopStack = context.loopStack.filter((f) => f.loopId !== node.id); + } for (const edge of doneEdges) { emit({ type: 'edge_traversed', edgeId: edge.id, source: edge.source, target: edge.target }); if (!queueSet.has(edge.target)) { diff --git a/src/apps/workflow/engine/node-executor.ts b/src/apps/workflow/engine/node-executor.ts index bb24e43..40db9ee 100644 --- a/src/apps/workflow/engine/node-executor.ts +++ b/src/apps/workflow/engine/node-executor.ts @@ -45,14 +45,19 @@ export async function executeNode( for (let attempt = 0; attempt <= maxRetries; attempt++) { state.retryCount = attempt; + let timedOut = false; try { result = await withTimeout(executor(resolvedConfig, context, emit), timeout); } catch (err) { const message = err instanceof Error ? err.message : String(err); + timedOut = err instanceof NodeTimeoutError; result = { status: 'failed', error: message }; } - if (result.status === 'passed' || attempt >= maxRetries) break; + // A timeout only rejects the race — the underlying executor keeps + // running. Retrying would start a SECOND concurrent instance of the same + // side effects (e.g. two deploy scripts), so timeouts are terminal. + if (result.status === 'passed' || timedOut || attempt >= maxRetries) break; if (attempt < maxRetries) { await sleep(retryDelay); @@ -78,9 +83,11 @@ export async function executeNode( return result; } +class NodeTimeoutError extends Error {} + function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Node timed out after ${ms}ms`)), ms); + const timer = setTimeout(() => reject(new NodeTimeoutError(`Node timed out after ${ms}ms`)), ms); promise.then( (val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); }, diff --git a/src/apps/workflow/src/mcp.ts b/src/apps/workflow/src/mcp.ts index 1a23f28..a6e243c 100644 --- a/src/apps/workflow/src/mcp.ts +++ b/src/apps/workflow/src/mcp.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { WorkflowStore } from '../services/workflow-store.js'; +import { validateWorkflowGraph } from '../services/workflow-validator.js'; import { getActiveProject } from '../../../project-context.js'; import type { Workflow, WorkflowNode, WorkflowEdge, VariableDefinition } from '../types.js'; import { jsonResult, errorResult, createDevglideMcpServer } from '../../../packages/mcp-utils/src/index.js'; @@ -144,6 +145,14 @@ export function createWorkflowMcpServer(): McpServer { } } + // Same graph validation the REST create route enforces — an invalid + // graph saved here (cycle, missing trigger, disconnected node) runs + // unvalidated later because POST /workflows/:id/run trusts the store. + const graphValidation = validateWorkflowGraph(parsedNodes, parsedEdges); + if (!graphValidation.valid) { + return errorResult(`Invalid workflow graph: ${graphValidation.errors.join('; ')}`); + } + const workflow = await store.save({ name, description, diff --git a/src/apps/workflow/types.ts b/src/apps/workflow/types.ts index 24065b4..ecf24c8 100644 --- a/src/apps/workflow/types.ts +++ b/src/apps/workflow/types.ts @@ -220,6 +220,18 @@ export interface ExecutionContext { startedAt: string; cancelled: boolean; loopContext?: LoopContext; + /** + * Active loop frames, outermost first. The runner sets `loopContext` per + * executing node from the innermost frame whose body contains that node — + * a single shared slot is clobbered by nested/parallel loops. + */ + loopStack?: Array<{ loopId: string; bodyIds: Set; ctx: LoopContext }>; + /** + * The run's root cancel token. Sub-workflows must receive this (not the + * parent's context) so external cancellation reaches them while the parent + * loop is parked awaiting the sub-workflow node. + */ + cancelToken?: { cancelled: boolean }; /** Snapshot of active project captured at workflow start — immune to mid-run changes */ project?: { id: string; name: string; path: string }; /** Injected service providers — decouples executors from app singletons */ diff --git a/src/packages/json-file-store.ts b/src/packages/json-file-store.ts index 5d516c0..5cd792e 100644 --- a/src/packages/json-file-store.ts +++ b/src/packages/json-file-store.ts @@ -98,7 +98,10 @@ export abstract class JsonFileStore { await fs.access(dest); // dest already exists — skip } catch { - await fs.rename(src, dest); + // Two concurrent first-writes for the same project can race this + // access/rename pair — losing the race just means the other call + // already moved the file, so ignore the failed rename. + try { await fs.rename(src, dest); } catch { /* already migrated */ } } } // Remove legacy dir if now empty @@ -168,6 +171,21 @@ export abstract class JsonFileStore { await fs.unlink(path.join(this.getGlobalDir(), `${id}.json`)); return true; } catch { + // No active project (stdio MCP mode): get() can find entities in any + // project dir, so delete() must be able to remove them there too. + if (!projectDir) { + const located = await this.locateExisting(id); + if (located?.scope === 'project' && located.projectId) { + try { + await fs.unlink(path.join(this.getDirForProject(located.projectId), `${id}.json`)); + return true; + } catch { /* fall through */ } + try { + await fs.unlink(path.join(this.baseDir, located.projectId, `${id}.json`)); + return true; + } catch { /* fall through */ } + } + } return false; } }); @@ -198,6 +216,39 @@ export abstract class JsonFileStore { // ── Scope resolution ────────────────────────────────────────────────── + /** + * Locate the scope (and owning project) an existing entity lives in. + * Unlike resolveExistingScope, this also searches all project dirs when + * no active project is set (stdio MCP mode) so updates/deletes act on the + * owning file instead of creating a shadowed global duplicate. + */ + protected async locateExisting(id: string): Promise<{ scope: 'project' | 'global'; projectId?: string } | undefined> { + const scope = await this.resolveExistingScope(id); + if (scope) return { scope, projectId: scope === 'project' ? getActiveProject()?.id : undefined }; + + const featureName = path.basename(this.baseDir); + // New project dirs: ~/.devglide/projects/{projectId}/{feature}/ + let projectIds: string[] = []; + try { projectIds = await fs.readdir(PROJECTS_DIR); } catch { /* none */ } + for (const projectId of projectIds) { + try { + await fs.access(path.join(PROJECTS_DIR, projectId, featureName, `${id}.json`)); + return { scope: 'project', projectId }; + } catch { /* keep looking */ } + } + // Legacy project dirs: baseDir/{projectId}/ + let names: string[] = []; + try { names = await fs.readdir(this.baseDir); } catch { /* none */ } + for (const name of names) { + if (name.endsWith('.json')) continue; + try { + await fs.access(path.join(this.baseDir, name, `${id}.json`)); + return { scope: 'project', projectId: name }; + } catch { /* keep looking */ } + } + return undefined; + } + protected async resolveExistingScope(id: string): Promise<'project' | 'global' | undefined> { this.assertSafeId(id); const projectDir = this.getProjectDir(); diff --git a/src/packages/mcp-utils/src/index.ts b/src/packages/mcp-utils/src/index.ts index 234ea4f..8e215de 100644 --- a/src/packages/mcp-utils/src/index.ts +++ b/src/packages/mcp-utils/src/index.ts @@ -96,7 +96,32 @@ export function mountMcpHttp( }, 5 * 60 * 1000); ttlTimer.unref(); - app.post(path, async (req: McpHttpRequest, res: ServerResponse) => { + // Express does not await async handlers — a rejection from + // transport.handleRequest would surface as a process-level + // unhandledRejection instead of a 500. Funnel through this guard. + const guard = + (fn: (req: McpHttpRequest, res: ServerResponse) => Promise) => + async (req: McpHttpRequest, res: ServerResponse) => { + try { + await fn(req, res); + } catch (err) { + console.error("[mcp-http] handler error:", err instanceof Error ? err.message : err); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal error" }, + id: null, + }) + ); + } else { + try { res.end(); } catch { /* socket already gone */ } + } + } + }; + + app.post(path, guard(async (req: McpHttpRequest, res: ServerResponse) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && sessions.has(sessionId)) { @@ -136,9 +161,9 @@ export function mountMcpHttp( id: null, }) ); - }); + })); - app.get(path, async (req: McpHttpRequest, res: ServerResponse) => { + app.get(path, guard(async (req: McpHttpRequest, res: ServerResponse) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (!sessionId || !sessions.has(sessionId)) { res.writeHead(400, { "Content-Type": "application/json" }); @@ -154,9 +179,9 @@ export function mountMcpHttp( const getSession = sessions.get(sessionId)!; getSession.lastAccessed = Date.now(); await getSession.transport.handleRequest(req, res); - }); + })); - app.delete(path, async (req: McpHttpRequest, res: ServerResponse) => { + app.delete(path, guard(async (req: McpHttpRequest, res: ServerResponse) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (!sessionId || !sessions.has(sessionId)) { res.writeHead(400, { "Content-Type": "application/json" }); @@ -172,7 +197,7 @@ export function mountMcpHttp( const delSession = sessions.get(sessionId)!; delSession.lastAccessed = Date.now(); await delSession.transport.handleRequest(req, res); - }); + })); } /** diff --git a/src/packages/ssrf-guard.ts b/src/packages/ssrf-guard.ts index 9c48a06..3d567a5 100644 --- a/src/packages/ssrf-guard.ts +++ b/src/packages/ssrf-guard.ts @@ -53,9 +53,19 @@ function isPrivateIP(ip: string): boolean { const firstHextet = parseInt(normalized.split(':')[0] || '0', 16); if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) return true; // link-local fe80::/10 if (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) return true; // ULA fc00::/7 - // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) + // IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1) const v4match = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (v4match) return isPrivateIP(v4match[1]); + // IPv4-mapped (hex form, e.g. ::ffff:7f00:1) and IPv4-compatible + // (e.g. ::7f00:1) — extract the low 32 bits and classify as IPv4. + // Some getaddrinfo implementations return the non-normalized hex form. + const hexMapped = normalized.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hexMapped) { + const hi = parseInt(hexMapped[1], 16); + const lo = parseInt(hexMapped[2], 16); + const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + return isPrivateIP(v4); + } return false; } diff --git a/src/routers/chat.ts b/src/routers/chat.ts index 073061b..1de1785 100644 --- a/src/routers/chat.ts +++ b/src/routers/chat.ts @@ -50,7 +50,9 @@ const messagesQuerySchema = z.object({ const pipeEventsQuerySchema = z.object({ limit: z.coerce.number().int().positive().optional(), since: z.string().optional(), - pipeId: z.string().optional(), + // The id is interpolated into a filesystem path by the store — allow only + // word chars and dashes (no separators, no dots → no traversal). + pipeId: z.string().regex(/^[\w-]+$/, 'Invalid pipeId').optional(), }); // ── Zod schemas (join/leave/send) ──────────────────────────────────────────── @@ -1367,7 +1369,14 @@ export function initChat(nsp: Namespace): void { // Handle clear from dashboard socket.on('chat:clear', () => { const socketProjectId = typeof socket.data.chatProjectId === 'string' ? socket.data.chatProjectId : null; - registry.clearHistory(socketProjectId); + try { + registry.clearHistory(socketProjectId); + } catch (err) { + // clearHistory does sync fs work (unlink/write) — a throw (e.g. + // Windows EBUSY on a locked pipe file) must not escape the socket.io + // listener. Mirrors the chat:send guard above. + socket.emit('chat:error', { error: err instanceof Error ? err.message : String(err) }); + } }); }); } diff --git a/src/routers/documentation.ts b/src/routers/documentation.ts index fc6b7d0..44f1818 100644 --- a/src/routers/documentation.ts +++ b/src/routers/documentation.ts @@ -145,17 +145,20 @@ router.put('/entries/:id', asyncHandler(async (req: Request, res: Response) => { badRequest(res, params.error.issues[0]?.message ?? 'Invalid input'); return; } - const existing = await store.get(params.data.id); - if (!existing) { notFound(res, 'Entry not found'); return; } - const parsed = updateEntrySchema.safeParse(req.body); if (!parsed.success) { badRequest(res, parsed.error.issues[0]?.message ?? 'Invalid input'); return; } - const merged = { ...existing, ...parsed.data.content, id: params.data.id, type: existing.type }; - const entry = await store.save(merged as any); + // Atomic read-merge-write inside the store lock — a separate get()+save() + // here loses concurrent field updates. Identity/type stay immutable. + const updates = { ...parsed.data.content } as Record; + delete updates.id; + delete updates.type; + delete updates.projectId; + const entry = await store.update(params.data.id, updates as Parameters[1]); + if (!entry) { notFound(res, 'Entry not found'); return; } res.json(entry); })); diff --git a/src/routers/shell/shell-routes.ts b/src/routers/shell/shell-routes.ts index 58675be..4d732fc 100644 --- a/src/routers/shell/shell-routes.ts +++ b/src/routers/shell/shell-routes.ts @@ -277,6 +277,16 @@ router.post('/panes/:id/run', asyncHandler(async (req: Request, res: Response) = } const { command, timeout } = parsed.data; + // Same project-ownership guard as DELETE — without it any project could + // inject commands into another project's terminal. + const paneInfo = getPaneInfo(paneId); + if (!paneInfo) { + return notFound(res, 'Pane not found'); + } + if (!isPaneOwnedByProject(paneInfo, getActiveProject()?.id || null)) { + return forbidden(res, 'Pane does not belong to active project'); + } + const entry = globalPtys.get(paneId); if (!entry) { return notFound(res, 'Pane not found'); @@ -343,6 +353,17 @@ router.get('/panes/:id/scrollback', (req: Request, res: Response) => { return badRequest(res, params.error.issues[0]?.message ?? 'Invalid input'); } const paneId = params.data.id; + + // Same project-ownership guard as DELETE — scrollback discloses another + // project's terminal output otherwise. + const paneInfo = getPaneInfo(paneId); + if (!paneInfo) { + return notFound(res, 'Pane not found'); + } + if (!isPaneOwnedByProject(paneInfo, getActiveProject()?.id || null)) { + return forbidden(res, 'Pane does not belong to active project'); + } + const entry = globalPtys.get(paneId); if (!entry) { diff --git a/src/routers/vocabulary.ts b/src/routers/vocabulary.ts index 6ee4a0a..8080249 100644 --- a/src/routers/vocabulary.ts +++ b/src/routers/vocabulary.ts @@ -117,9 +117,6 @@ router.put('/entries/:id', asyncHandler(async (req: Request, res: Response) => { badRequest(res, params.error.issues[0]?.message ?? 'Invalid input'); return; } - const existing = await store.get(params.data.id); - if (!existing) { notFound(res, 'Entry not found'); return; } - const parsed = updateEntrySchema.safeParse(req.body); if (!parsed.success) { badRequest(res, parsed.error.issues[0]?.message ?? 'Invalid input'); @@ -128,15 +125,10 @@ router.put('/entries/:id', asyncHandler(async (req: Request, res: Response) => { const { term, definition, aliases, category, tags } = parsed.data; - const entry = await store.save({ - id: params.data.id, - term: term ?? existing.term, - definition: definition ?? existing.definition, - aliases: aliases ?? existing.aliases, - category: category ?? existing.category, - tags: tags ?? existing.tags, - projectId: existing.projectId, - }); + // Atomic read-merge-write inside the store lock — a separate get()+save() + // here loses concurrent field updates. + const entry = await store.update(params.data.id, { term, definition, aliases, category, tags }); + if (!entry) { notFound(res, 'Entry not found'); return; } res.json(entry); })); diff --git a/src/server.ts b/src/server.ts index 3788881..b25a5b5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,7 @@ import { chatServerSessions, registerChatMcpHttpSession, unregisterChatMcpHttpSession, + isNameTrackedByAnotherSession, } from './routers/chat.js'; import { router as documentationRouter, createDocumentationMcpServer } from './routers/documentation.js'; import * as chatRegistry from './apps/chat/services/chat-registry.js'; @@ -57,7 +58,7 @@ import * as chatRegistry from './apps/chat/services/chat-registry.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, '..'); -const PORT = parseInt(process.env.PORT || '7000', 10); +const PORT = parseInt(process.env.DEVGLIDE_PORT || process.env.PORT || '7000', 10); // --------------------------------------------------------------------------- // Server sniffer — captures server-side console output to disk @@ -123,6 +124,15 @@ app.use((req, res, next) => { (isVoiceUpload ? jsonLarge : jsonDefault)(req, res, next); }); +// --------------------------------------------------------------------------- +// Health check — used by the CLI to verify a daemon actually bound the port +// --------------------------------------------------------------------------- + +const SERVER_STARTED_AT = new Date().toISOString(); +app.get('/api/health', (_req, res) => { + res.json({ ok: true, pid: process.pid, startedAt: SERVER_STARTED_AT }); +}); + // --------------------------------------------------------------------------- // Static file serving // --------------------------------------------------------------------------- @@ -271,7 +281,13 @@ mountMcpHttp(app, createChatMcpServer, '/mcp/chat', { if (entries) { // Detach instead of leave — alias stays reserved for reclaim on rejoin. // Full removal happens only on pane closure or explicit chat_leave. - for (const entry of entries) chatRegistry.detach(entry.name, entry.projectId); + // Skip participants that a NEWER live session has since reclaimed + // (e.g. CLI restarted in the same pane, old session TTL-reaped later) — + // detaching them would fail their running pipes and eject a live agent. + for (const entry of entries) { + if (isNameTrackedByAnotherSession(server, entry.name, entry.projectId)) continue; + chatRegistry.detach(entry.name, entry.projectId); + } chatServerSessions.delete(server); } unregisterChatMcpHttpSession(server, sessionId); @@ -326,6 +342,14 @@ async function bootstrap() { if (stored) setActiveProject(stored); // Start listening + httpServer.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`[devglide] port :${PORT} is already in use — is another devglide server running?`); + } else { + console.error('[devglide] server failed to listen:', err); + } + process.exit(1); + }); httpServer.listen(PORT, () => { console.log(`[devglide] unified server listening on http://localhost:${PORT}`); });