From 0ecdde49fef49006f2073fb26b69ec73177f2059 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 24 Apr 2026 13:28:03 -0300 Subject: [PATCH 1/6] refactor(vm/daemon): port to Bun.serve + web-standard Request/Response Ports the in-VM daemon from Node's http.createServer to Bun.serve with web-standard Request/Response handlers, fetch()-based reverse proxy and liveness probe, ReadableStream SSE, and AbortSignal.timeout for client timeouts. Top-level imports are ESM; only OS-level concerns (fs, path, child_process, crypto.timingSafeEqual) still come from node: modules. Runner-side: VmBun is now attached to every VmSpec unconditionally (not only when runtime === "bun"), /opt/run-daemon.sh invokes bun directly (with nvm sourced first so child processes inherit corepack/node on PATH), and install-bun.service joins the daemon service's requires/after list. Resolves the VM start hang caused by the old Node launcher dropping PATH for the daemon's child processes. Setup hardening baked in along the way: re-entry guard + resume-on- restart in runSetup, SSE replay ordered before live-broadcast registration, spawnSync-based gitSync helper that surfaces stderr on failure, git safe.directory /app prepended so git 2.35+ can't trip on dubious-ownership, rg output limiting with SIGTERM escalation so large result sets can't wedge the pipe, SIGKILL escalation for stuck processes, and X-Accel-Buffering: no on the SSE stream so edge proxies flush chunks immediately. New daemon-script.e2e.test.ts boots the generated script under Bun on a random port and exercises auth, SSE replay + live broadcast, exec/setup 409 re-entry, bash timeout, reverse-proxy HTML bootstrap injection, chunked POST forwarding, and CORS headers on every response branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../freestyle/daemon-script.e2e.test.ts | 639 ++++++++++ .../server/runner/freestyle/daemon-script.ts | 1059 ++++++++++------- .../server/runner/freestyle/runner.ts | 15 +- 3 files changed, 1290 insertions(+), 423 deletions(-) create mode 100644 packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts diff --git a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts new file mode 100644 index 0000000000..585b3a1b9d --- /dev/null +++ b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts @@ -0,0 +1,639 @@ +/** + * End-to-end smoke tests for the in-VM daemon. + * + * Spawns the generated daemon script on a random localhost port under Bun + * and exercises real HTTP/SSE endpoints. Since the daemon uses `spawn` with + * uid/gid=1000 ("deco" user) in production, this test strips those when + * running outside the VM — a helper below creates a sandboxed /tmp/app dir + * and writes a patched daemon script with uid/gid removed. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { buildDaemonScript } from "./daemon-script"; + +const DAEMON_TOKEN = "t".repeat(32); + +function authHeaders( + extra: Record = {}, +): Record { + return { Authorization: `Bearer ${DAEMON_TOKEN}`, ...extra }; +} + +let daemonProc: ChildProcess | null = null; +let daemonPort = 0; +let appDir = ""; + +async function waitForPort(port: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch( + `http://localhost:${port}/_decopilot_vm/scripts`, + { + headers: authHeaders(), + }, + ); + if (res.ok) return; + } catch { + /* server not up yet */ + } + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`daemon did not listen on :${port} within ${timeoutMs}ms`); +} + +function freePort(): number { + // 50000-59999 range to avoid clashing with dev servers + return 50000 + Math.floor(Math.random() * 10000); +} + +async function startDaemon() { + appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); + daemonPort = freePort(); + const script = buildDaemonScript({ + upstreamPort: "3000", + packageManager: null, + pathPrefix: "", + port: "3000", + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }) + // Strip uid/gid so spawn works outside the VM (we're not root here). + // Match the full pair including the leading comma so we don't leave + // dangling commas or lone gid when uid/gid appear right before `}`. + .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") + // Use our temp dir for APP_ROOT + .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + + const scriptPath = join(appDir, "daemon.js"); + writeFileSync(scriptPath, script); + + daemonProc = spawn("bun", [scriptPath], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + // Surface daemon logs when the test fails + daemonProc.stdout?.on("data", (c) => + process.stderr.write(`[daemon:out] ${c}`), + ); + daemonProc.stderr?.on("data", (c) => + process.stderr.write(`[daemon:err] ${c}`), + ); + await waitForPort(daemonPort); +} + +async function stopDaemon() { + if (daemonProc) { + daemonProc.kill("SIGKILL"); + daemonProc = null; + } + if (appDir) { + rmSync(appDir, { recursive: true, force: true }); + appDir = ""; + } +} + +describe("daemon e2e (runs generated script under Bun)", () => { + beforeEach(async () => { + await startDaemon(); + }); + afterEach(async () => { + await stopDaemon(); + }); + + it("GET /_decopilot_vm/scripts returns { scripts: [] } before discovery", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/scripts`, + { headers: authHeaders() }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = (await res.json()) as { scripts: string[] }; + expect(body.scripts).toEqual([]); + }); + + it("rejects unauthenticated /_decopilot_vm/* with 401", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/scripts`, + ); + expect(res.status).toBe(401); + }); + + it("POST /_decopilot_vm/bash executes a command and returns stdout", async () => { + // Base64-wrap is the permanent wire format (WAF bypass); see daemon-script.ts header. + const raw = JSON.stringify({ command: "echo hello-world" }); + const b64 = Buffer.from(raw, "utf-8").toString("base64"); + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: b64, + }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + stdout: string; + stderr: string; + exitCode: number; + }; + expect(body.stdout.trim()).toBe("hello-world"); + expect(body.exitCode).toBe(0); + }); + + it("GET /_decopilot_vm/events streams an SSE status event on connect", async () => { + const ctrl = new AbortController(); + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/events`, + { signal: ctrl.signal, headers: authHeaders() }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + expect(res.headers.get("x-accel-buffering")).toBe("no"); + + const reader = res.body!.getReader(); + const chunk = await reader.read(); + const text = new TextDecoder().decode(chunk.value); + expect(text).toContain("event: status"); + expect(text).toContain("data:"); + ctrl.abort(); + }); + + it("OPTIONS /_decopilot_vm/bash returns CORS headers (no auth required)", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { method: "OPTIONS" }, + ); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-methods")).toContain("POST"); + expect(res.headers.get("access-control-allow-headers")).toContain( + "Authorization", + ); + }); + + it("SSE replays buffered events on connect and delivers live broadcasts", async () => { + // Fire a request to produce a log line in the "daemon" replay buffer. + await fetch(`http://localhost:${daemonPort}/_decopilot_vm/scripts`, { + headers: authHeaders(), + }); + // Give the daemon a moment to append to its replay buffer. + await new Promise((r) => setTimeout(r, 50)); + + const ctrl = new AbortController(); + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/events`, + { signal: ctrl.signal, headers: authHeaders() }, + ); + expect(res.status).toBe(200); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + // First chunk should include the `status` event (replay). + const first = await reader.read(); + const firstText = decoder.decode(first.value); + expect(firstText).toContain("event: status"); + + // Trigger a new log line by hitting the proxy fallthrough, and confirm + // we see it live on the SSE stream within a deadline. + const deadline = Date.now() + 3000; + let saw = false; + await fetch(`http://localhost:${daemonPort}/something-live`).catch(() => { + /* proxy upstream likely 502 — we only care about the log side-effect */ + }); + while (!saw && Date.now() < deadline) { + const r = await reader.read(); + if (r.done) break; + const t = decoder.decode(r.value); + if (t.includes("proxy") && t.includes("something-live")) saw = true; + } + expect(saw).toBe(true); + ctrl.abort(); + }); + + it("POST /_decopilot_vm/exec/setup triggers re-setup and returns { ok: true }", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/exec/setup`, + { method: "POST", headers: authHeaders() }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("POST /_decopilot_vm/exec/setup returns 409 when setup is already running", async () => { + // The daemon auto-triggers setup on boot; by the time this test runs + // the boot setup is typically still in-flight (clone fails fast against + // invalid.example.com but leaves a blocked setupRunning window around + // spawn events). Fire two POSTs back-to-back and expect exactly one + // 200 and one 409 — the re-entry guard rejects the concurrent call. + const first = fetch( + `http://localhost:${daemonPort}/_decopilot_vm/exec/setup`, + { method: "POST", headers: authHeaders() }, + ); + const second = fetch( + `http://localhost:${daemonPort}/_decopilot_vm/exec/setup`, + { method: "POST", headers: authHeaders() }, + ); + const [r1, r2] = await Promise.all([first, second]); + const statuses = [r1.status, r2.status].sort(); + expect(statuses).toEqual([200, 409]); + }); + + it("POST /_decopilot_vm/exec/ before setup returns 400", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/exec/dev`, + { method: "POST", headers: authHeaders() }, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("setup not complete"); + }); + + it("POST /_decopilot_vm/kill/ when process isn't running returns 400", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/kill/nonexistent`, + { method: "POST", headers: authHeaders() }, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("not running"); + }); + + it("POST /_decopilot_vm/grep and /_decopilot_vm/glob succeed (confirms uid/gid stripped from spawn)", async () => { + // Create a file in appDir to search + const sampleFile = join(appDir, "needle.txt"); + writeFileSync(sampleFile, "hello world\n"); + + const toBody = (obj: unknown) => + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + + const grepRes = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/grep`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ pattern: "hello", output_mode: "content" }), + }, + ); + expect(grepRes.status).toBe(200); + const grepBody = (await grepRes.json()) as { results: string }; + expect(grepBody.results).toContain("hello world"); + + const globRes = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/glob`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ pattern: "*.txt" }), + }, + ); + expect(globRes.status).toBe(200); + const globBody = (await globRes.json()) as { files: string[] }; + expect(globBody.files).toContain("needle.txt"); + }); + + it("POST /_decopilot_vm/read returns file contents with line numbers", async () => { + const sampleFile = join(appDir, "greet.txt"); + writeFileSync(sampleFile, "line1\nline2\nline3\n"); + const toBody = (obj: unknown) => + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/read`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ path: "greet.txt" }), + }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { content: string; lineCount: number }; + expect(body.content).toContain("1\tline1"); + expect(body.content).toContain("2\tline2"); + expect(body.lineCount).toBeGreaterThanOrEqual(3); + }); + + it("POST /_decopilot_vm/write + /edit round-trip", async () => { + const toBody = (obj: unknown) => + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + + const wr = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/write`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ path: "ed.txt", content: "hello world" }), + }, + ); + expect(wr.status).toBe(200); + + const ed = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/edit`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ + path: "ed.txt", + old_string: "world", + new_string: "bun", + }), + }, + ); + expect(ed.status).toBe(200); + const edBody = (await ed.json()) as { ok: boolean; replacements: number }; + expect(edBody.ok).toBe(true); + expect(edBody.replacements).toBe(1); + }); + + it("POST /_decopilot_vm/bash with a timeout-killed command resolves with exitCode=-1 (does not hang)", async () => { + // Exercises the same Promise-resolution path as a spawn "error" event: + // child terminates externally (timeout-triggered SIGKILL) and close + // resolves the await promise with -1. If handleBash ever hangs on + // spawn failures, this test would time out. + const toBody = (obj: unknown) => + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ command: "sleep 30", timeout: 500 }), + }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { exitCode: number }; + expect(body.exitCode).toBe(-1); + }); + + it("POST /_decopilot_vm/bash with invalid base64 body returns 400", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: "not-valid-base64-!!@#$", + }, + ); + expect(res.status).toBe(400); + }); + + it("daemon stays up and keeps probing upstream even when upstream is unreachable", async () => { + // UPSTREAM is :3000 (per startDaemon fixture) — nothing is listening there. + // The probe should fail gracefully and not crash the daemon. Give it time + // for at least one probe cycle (1s initial + 3s fast interval), then + // confirm the daemon is still responsive. + await new Promise((r) => setTimeout(r, 1500)); + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/scripts`, + { headers: authHeaders() }, + ); + expect(res.status).toBe(200); + }); +}); + +describe("daemon e2e (Bun-native server guarantees)", () => { + beforeEach(async () => { + await startDaemon(); + }); + afterEach(async () => { + await stopDaemon(); + }); + + it("returns Access-Control-Allow-Origin=* on every /_decopilot_vm/* response branch", async () => { + // 1. GET /scripts (native Response branch) + const scripts = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/scripts`, + { headers: authHeaders() }, + ); + expect(scripts.headers.get("access-control-allow-origin")).toBe("*"); + + // 2. OPTIONS preflight (native Response branch) + const preflight = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { method: "OPTIONS" }, + ); + expect(preflight.headers.get("access-control-allow-origin")).toBe("*"); + + // 3. POST /bash (Bun-native Response) + const bashBody = Buffer.from( + JSON.stringify({ command: "true" }), + "utf-8", + ).toString("base64"); + const bash = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/bash`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: bashBody, + }, + ); + expect(bash.headers.get("access-control-allow-origin")).toBe("*"); + + // 4. GET unknown daemon route (404 catch-all) + const missing = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/does-not-exist`, + { headers: authHeaders() }, + ); + expect(missing.headers.get("access-control-allow-origin")).toBe("*"); + }); + + it("unknown daemon route returns 404 JSON (not a proxy forward)", async () => { + const res = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/does-not-exist`, + { headers: authHeaders() }, + ); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Not found"); + }); +}); + +describe("daemon e2e (reverse proxy)", () => { + let upstreamServer: ReturnType | null = null; + let upstreamPort = 0; + + async function startWithUpstream( + upstreamHandler: (req: Request) => Response | Promise, + ) { + upstreamServer = Bun.serve({ port: 0, fetch: upstreamHandler }); + upstreamPort = upstreamServer.port as number; + appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); + daemonPort = freePort(); + const script = buildDaemonScript({ + upstreamPort: String(upstreamPort), + packageManager: null, + pathPrefix: "", + port: String(upstreamPort), + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }) + .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") + .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + const scriptPath = join(appDir, "daemon.js"); + writeFileSync(scriptPath, script); + daemonProc = spawn("bun", [scriptPath], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + daemonProc.stdout?.on("data", (c) => + process.stderr.write(`[daemon:out] ${c}`), + ); + daemonProc.stderr?.on("data", (c) => + process.stderr.write(`[daemon:err] ${c}`), + ); + await waitForPort(daemonPort); + } + + async function startWithoutUpstream() { + // Point upstream at a port where nothing is listening. + upstreamPort = freePort(); + appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); + daemonPort = freePort(); + const script = buildDaemonScript({ + upstreamPort: String(upstreamPort), + packageManager: null, + pathPrefix: "", + port: String(upstreamPort), + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }) + .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") + .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + const scriptPath = join(appDir, "daemon.js"); + writeFileSync(scriptPath, script); + daemonProc = spawn("bun", [scriptPath], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + daemonProc.stdout?.on("data", (c) => + process.stderr.write(`[daemon:out] ${c}`), + ); + daemonProc.stderr?.on("data", (c) => + process.stderr.write(`[daemon:err] ${c}`), + ); + await waitForPort(daemonPort); + } + + afterEach(async () => { + await stopDaemon(); + if (upstreamServer) { + upstreamServer.stop(true); + upstreamServer = null; + } + }); + + it("injects BOOTSTRAP and strips XFO/CSP/content-encoding for HTML", async () => { + await startWithUpstream( + () => + new Response("

hi

", { + headers: { + "Content-Type": "text/html", + "X-Frame-Options": "DENY", + "Content-Security-Policy": "default-src 'none'", + }, + }), + ); + + const res = await fetch(`http://localhost:${daemonPort}/page`); + expect(res.status).toBe(200); + expect(res.headers.get("x-frame-options")).toBeNull(); + expect(res.headers.get("content-security-policy")).toBeNull(); + expect(res.headers.get("content-encoding")).toBeNull(); + const body = await res.text(); + expect(body).toContain(""); + }); + + it("passes through non-HTML responses untouched", async () => { + await startWithUpstream(() => + Response.json({ ok: true }, { headers: { "X-Frame-Options": "DENY" } }), + ); + + const res = await fetch(`http://localhost:${daemonPort}/api/data`); + expect(res.status).toBe(200); + expect(res.headers.get("x-frame-options")).toBeNull(); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 503 'Server is starting' HTML when upstream is unreachable at /", async () => { + await startWithoutUpstream(); + const res = await fetch(`http://localhost:${daemonPort}/`); + expect(res.status).toBe(503); + expect(res.headers.get("retry-after")).toBe("1"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + const body = await res.text(); + expect(body).toContain("Server is starting"); + }); + + it("returns 502 JSON when upstream is unreachable at a non-root path", async () => { + await startWithoutUpstream(); + const res = await fetch(`http://localhost:${daemonPort}/api/thing`); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("proxy error"); + }); + + it("forwards POST bodies to upstream", async () => { + let receivedBody = ""; + await startWithUpstream(async (req) => { + receivedBody = await req.text(); + return Response.json({ ok: true }); + }); + + const res = await fetch(`http://localhost:${daemonPort}/api/echo`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hello: "world" }), + }); + expect(res.status).toBe(200); + expect(receivedBody).toBe('{"hello":"world"}'); + }); + + it("forwards chunked POST bodies to upstream", async () => { + let receivedBody = ""; + await startWithUpstream(async (req) => { + receivedBody = await req.text(); + return Response.json({ ok: true }); + }); + + const stream = new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode("chunk1 ")); + c.enqueue(new TextEncoder().encode("chunk2")); + c.close(); + }, + }); + // `duplex: "half"` required by fetch when streaming a request body. + const res = await fetch(`http://localhost:${daemonPort}/api/echo`, { + method: "POST", + body: stream, + // @ts-expect-error — duplex is valid but not in all TS lib types + duplex: "half", + }); + expect(res.status).toBe(200); + expect(receivedBody).toBe("chunk1 chunk2"); + }); +}); diff --git a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.ts b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.ts index 9619849713..854be5ac0b 100644 --- a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.ts +++ b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.ts @@ -1,6 +1,15 @@ /** * Freestyle in-VM daemon builder. Output is a JS string baked into the * VmSpec and started by systemd — updates require recreating freestyle VMs. + * + * Runs under /opt/bun/bin/bun on every VM (Bun is attached unconditionally + * in the runner's VmSpec). Uses Bun.serve with web-standard Request/Response + * and a ReadableStream for SSE. + * + * Body wire format: POST bodies are base64-encoded JSON (see + * parseBase64JsonBody). This is NOT legacy — it's a Cloudflare WAF bypass + * applied by the mesh server's daemonPost helper to avoid CF triggering on + * shell commands in /bash etc. */ /** Inlined so the runner package stays self-contained (no upward import). */ @@ -64,11 +73,11 @@ export function buildDaemonScript(config: DaemonConfig): string { ); } - return `const http = require("http"); -const fs = require("fs"); -const path = require("path"); -const { spawn, execSync } = require("child_process"); -const { timingSafeEqual } = require("crypto"); + return `// Generated by buildDaemonScript — runs under Bun. +import fs from "node:fs"; +import path from "node:path"; +import { spawn, spawnSync, execSync } from "node:child_process"; +import { timingSafeEqual } from "node:crypto"; const UPSTREAM = "${upstreamPort}"; const UPSTREAM_HOST = "localhost"; const PROXY_PORT = ${proxyPort}; @@ -88,7 +97,7 @@ const EXPECTED_AUTH = Buffer.from("Bearer " + ${JSON.stringify(daemonToken)}, "u // user's dev server content). timingSafeEqual requires equal-length buffers; // the length itself leaks nothing useful (scheme + token length are fixed). function authorized(req) { - const header = req.headers["authorization"] || ""; + const header = req.headers.get("authorization") || ""; const received = Buffer.from(header, "utf8"); if (received.length !== EXPECTED_AUTH.length) return false; return timingSafeEqual(received, EXPECTED_AUTH); @@ -121,39 +130,25 @@ function safePath(userPath) { } // --- JSON body parser (base64-encoded payloads) --- -function parseJsonBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - const raw = Buffer.concat(chunks).toString("utf-8"); - log("parseJsonBody", "url=" + req.url, "rawLength=" + raw.length); - try { - // Decode base64 → percent-encoded UTF-8 → original JSON string - const decoded = decodeURIComponent( - atob(raw).split("").map(function(c) { - return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); - }).join("") - ); - const parsed = JSON.parse(decoded); - log("parseJsonBody", "parsed OK, keys=" + Object.keys(parsed).join(",")); - resolve(parsed); - } catch (e) { - log("parseJsonBody", "FAILED to parse", "error=" + e.message, "raw=" + raw.slice(0, 1000)); - reject(new Error("Failed to parse body: " + e.message + " | raw=" + raw.slice(0, 200))); - } - }); - req.on("error", reject); - }); -} - -function jsonResponse(res, statusCode, body) { - if (res.writableEnded || res.destroyed) { - log("jsonResponse: response already closed, dropping", statusCode, JSON.stringify(body).slice(0, 200)); - return; +// Callers (mesh server's daemonPost helper) wrap request bodies in base64 to +// avoid Cloudflare WAF triggering on shell commands in /bash etc. We keep +// the contract: base64 → percent-encoded UTF-8 → JSON.parse → object. +async function parseBase64JsonBody(req) { + const raw = await req.text(); + log("parseBase64JsonBody", "url=" + new URL(req.url).pathname, "rawLength=" + raw.length); + try { + const decoded = decodeURIComponent( + atob(raw).split("").map(function(c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }).join("") + ); + const parsed = JSON.parse(decoded); + log("parseBase64JsonBody", "parsed OK, keys=" + Object.keys(parsed).join(",")); + return parsed; + } catch (e) { + log("parseBase64JsonBody", "FAILED to parse", "error=" + e.message, "raw=" + raw.slice(0, 1000)); + throw new Error("Failed to parse body: " + e.message + " | raw=" + raw.slice(0, 200)); } - res.writeHead(statusCode, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify(body)); } // --- Logging --- @@ -165,14 +160,24 @@ function log(...args) { } // --- SSE state --- +// sseClients holds ReadableStreamDefaultController objects (Bun-native SSE). const sseClients = new Set(); let lastStatus = { ready: false, htmlSupport: false }; +const textEncoder = new TextEncoder(); + +function sseFormat(eventName, payload) { + return textEncoder.encode( + "event: " + eventName + "\\ndata: " + payload + "\\n\\n", + ); +} + // --- Process state --- const children = {}; const replayBuffers = { setup: "", daemon: "" }; const REPLAY_BYTES = 4096; let setupDone = false; +let setupRunning = false; let discoveredScripts = null; let lastBranchStatus = null; let branchStatusTimer = null; @@ -184,58 +189,77 @@ function broadcastChunk(source, data) { const buf = replayBuffers[source] + data; replayBuffers[source] = buf.length > REPLAY_BYTES ? buf.slice(buf.length - REPLAY_BYTES) : buf; const payload = JSON.stringify({ source: source, data: data }); - for (const res of sseClients) { - if (res.writable) res.write("event: log\\ndata: " + payload + "\\n\\n"); + const bytes = sseFormat("log", payload); + for (const c of sseClients) { + try { c.enqueue(bytes); } catch (e) {} } } function broadcastEvent(eventName, data) { - const payload = JSON.stringify(data); - for (const res of sseClients) { - if (res.writable) res.write("event: " + eventName + "\\ndata: " + payload + "\\n\\n"); + const bytes = sseFormat(eventName, JSON.stringify(data)); + for (const c of sseClients) { + try { c.enqueue(bytes); } catch (e) {} } } +// Run git with an argv array via spawnSync. Avoids shell parsing quirks in +// Bun's execSync and surfaces the child's stderr on failure (Bun's execSync +// drops stderr when thrown). asUser=false runs as whoever the daemon is +// (root under systemd) — used for system-level config like safe.directory. +function gitSync(args, opts) { + const asUser = opts && opts.asUser === false ? false : true; + const spawnOpts = { + cwd: (opts && opts.cwd) || APP_ROOT, + env: DECO_ENV, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }; + if (asUser) { spawnOpts.uid = DECO_UID; spawnOpts.gid = DECO_GID; } + const res = spawnSync("git", args, spawnOpts); + if (res.error) { + const err = new Error("git " + args.join(" ") + ": " + res.error.message); + err.stderr = String(res.stderr || ""); + throw err; + } + if (res.status !== 0) { + const err = new Error("git " + args.join(" ") + " exited " + res.status + (res.stderr ? ": " + String(res.stderr).trim() : "")); + err.stderr = String(res.stderr || ""); + err.status = res.status; + throw err; + } + return String(res.stdout || "").trim(); +} + function computeBranchStatus() { - const exec = (cmd) => { - try { - return execSync(cmd, { - cwd: APP_ROOT, - uid: DECO_UID, - gid: DECO_GID, - env: DECO_ENV, - stdio: ["ignore", "pipe", "ignore"], - }).toString().trim(); - } catch (e) { - return ""; - } + const exec = (args) => { + try { return gitSync(args); } catch (e) { return ""; } }; - const refExists = (ref) => exec("git rev-parse --verify --quiet " + JSON.stringify(ref)).length > 0; + const refExists = (ref) => exec(["rev-parse", "--verify", "--quiet", ref]).length > 0; try { - const branch = exec("git rev-parse --abbrev-ref HEAD"); + const branch = exec(["rev-parse", "--abbrev-ref", "HEAD"]); if (!branch || branch === "HEAD") return null; - let base = exec("git symbolic-ref --short refs/remotes/origin/HEAD"); + let base = exec(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]); if (base.startsWith("origin/")) base = base.slice("origin/".length); if (!base) base = "main"; - const dirty = exec("git status --porcelain=v1").length > 0; + const dirty = exec(["status", "--porcelain=v1"]).length > 0; // origin/ may not exist (not fetched yet, or local-only). Fall back // to HEAD on the "branch" side when it's missing — we can still measure // ahead-of-base that way; unpushed stays 0 because we can't see a diff. const branchRef = refExists("origin/" + branch) ? "origin/" + branch : "HEAD"; const unpushed = branchRef === "origin/" + branch - ? Number(exec("git rev-list --count origin/" + branch + "..HEAD") || "0") + ? Number(exec(["rev-list", "--count", "origin/" + branch + "..HEAD"]) || "0") : 0; let aheadOfBase = 0, behindBase = 0; if (refExists("origin/" + base)) { - const lrcount = exec("git rev-list --left-right --count origin/" + base + "..." + branchRef); + const lrcount = exec(["rev-list", "--left-right", "--count", "origin/" + base + "..." + branchRef]); const m = lrcount.match(/^(\\d+)\\s+(\\d+)$/); if (m) { behindBase = Number(m[1]); aheadOfBase = Number(m[2]); } } // Current head sha — used by the frontend to detect branch advances // past a merged PR's head. - const headSha = exec("git rev-parse " + branchRef); + const headSha = exec(["rev-parse", branchRef]); return { branch: branch, base: base, workingTreeDirty: dirty, unpushed: unpushed, aheadOfBase: aheadOfBase, behindBase: behindBase, headSha: headSha }; } catch (e) { log("branch-status compute failed:", e && e.message ? e.message : e); @@ -275,8 +299,16 @@ function watchGitDir() { function runProcess(source, cmd, label) { if (children[source]) { - log("killing", source, "pid=" + children[source].pid); - try { children[source].kill("SIGKILL"); } catch (e) {} + const old = children[source]; + log("killing", source, "pid=" + old.pid); + // Detach stdout/stderr listeners first so the old child's trailing + // output (after we start the new one but before the kernel reaps it) + // doesn't interleave with the new child's stream under the same source. + try { + if (old.stdout) old.stdout.removeAllListeners("data"); + if (old.stderr) old.stderr.removeAllListeners("data"); + } catch (e) {} + try { old.kill("SIGKILL"); } catch (e) {} children[source] = null; } if (!replayBuffers[source]) replayBuffers[source] = ""; @@ -296,6 +328,13 @@ function runProcess(source, cmd, label) { child.stderr.on("data", (chunk) => { broadcastChunk(source, chunk.toString("utf-8")); }); + child.on("error", (err) => { + log("spawn error", source, err && err.message ? err.message : String(err)); + if (children[source] === child) { + children[source] = null; + broadcastEvent("processes", { type: "processes", active: Object.keys(children).filter(k => children[k] !== null) }); + } + }); child.on("close", (code) => { log(source, "exited", "pid=" + child.pid, "code=" + code); if (children[source] === child) children[source] = null; @@ -358,32 +397,50 @@ function runSetup() { log("invalid branch name: " + BRANCH); return; } - const cloneCmd = "git clone --depth 1 " + CLONE_URL + " /app"; - const cloneLabel = "$ git clone --depth 1 " + REPO_NAME + " /app"; - broadcastChunk("setup", cloneLabel + "\\r\\n"); - const child = spawn("script", ["-q", "-c", cloneCmd, "/dev/null"], { - stdio: ["ignore", "pipe", "pipe"], - uid: DECO_UID, - gid: DECO_GID, - env: DECO_ENV, - }); - log("spawned setup (clone) pid=" + child.pid); - child.stdout.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); - child.stderr.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); - child.on("close", (code) => { - log("clone exited code=" + code); - if (code !== 0) { - broadcastChunk("setup", "\\r\\nClone failed with exit code " + code + "\\r\\n"); - return; + // Re-entry guard: reject overlapping calls (boot auto-setup + HTTP exec/setup, + // or two HTTP exec/setup calls in quick succession). Cleared in every + // terminal branch below so a failed run can be retried. + if (setupRunning) { + log("runSetup ignored: setup already running"); + broadcastChunk("setup", "\\r\\nSetup already running; ignoring re-entry\\r\\n"); + return; + } + setupRunning = true; + + // Mark setup complete + side effects; call from every terminal branch. + const finish = (success) => { + setupRunning = false; + setupDone = true; + emitBranchStatus(); + watchGitDir(); + if (success) { + log("setup complete, discovering scripts"); + discoverScripts(); + } + }; + + // Second phase after clone (or resume when /app/.git already exists): set + // git identity, resolve BRANCH, then run install. Hoisted into a named + // function so both paths can reuse it. + function proceedAfterClone() { + // Tell git to trust /app regardless of who runs it. git 2.35+ rejects + // repos whose working tree is owned by a different uid than the caller + // ("dubious ownership"), which bites us when any future refactor runs + // git as a different user than /app's owner. System-level config, no + // uid drop — we're root here. + try { + gitSync(["config", "--system", "--add", "safe.directory", APP_ROOT], { asUser: false }); + } catch (e) { + log("git safe.directory setup failed:", e.message); } // Configure git identity. try { - execSync("git config user.name " + JSON.stringify(GIT_USER_NAME), { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV }); - execSync("git config user.email " + JSON.stringify(GIT_USER_EMAIL), { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV }); + gitSync(["config", "user.name", GIT_USER_NAME]); + gitSync(["config", "user.email", GIT_USER_EMAIL]); } catch (e) { - log("git identity setup failed:", e.message); + log("git identity setup failed:", e.message, e.stderr || ""); broadcastChunk("setup", "\\r\\nWarning: could not set up git identity\\r\\n"); } @@ -396,48 +453,52 @@ function runSetup() { // fetch using the BRANCH:BRANCH refspec below. let branchOnRemote = false; try { - execSync( - "git fetch origin " + - JSON.stringify("+refs/heads/" + BRANCH + ":refs/remotes/origin/" + BRANCH), - { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV, stdio: "pipe" }, - ); - execSync( - "git fetch origin " + JSON.stringify(BRANCH) + ":" + JSON.stringify(BRANCH), - { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV, stdio: "pipe" }, - ); + gitSync(["fetch", "origin", "+refs/heads/" + BRANCH + ":refs/remotes/origin/" + BRANCH]); + gitSync(["fetch", "origin", BRANCH + ":" + BRANCH]); branchOnRemote = true; } catch (e) { // Branch doesn't exist on remote — create it locally below. log("fetch origin " + BRANCH + " failed (branch likely absent remote): " + (e && e.message ? e.message : e)); } + // On resume, the branch may already be checked out locally. Try a + // plain checkout first (no-ops on the current branch, switches if + // present locally); fall back to checkout -b when the local branch + // doesn't exist yet. try { if (branchOnRemote) { - execSync("git checkout " + JSON.stringify(BRANCH), { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV }); + gitSync(["checkout", BRANCH]); broadcastChunk("setup", "\\r\\n$ git checkout " + BRANCH + " (from origin)\\r\\n"); log("checked out " + BRANCH + " from remote"); } else { - execSync("git checkout -b " + JSON.stringify(BRANCH), { cwd: "/app", uid: DECO_UID, gid: DECO_GID, env: DECO_ENV }); - broadcastChunk("setup", "\\r\\n$ git checkout -b " + BRANCH + " (new local)\\r\\n"); - log("created local branch " + BRANCH + " off default"); + try { + gitSync(["checkout", BRANCH]); + broadcastChunk("setup", "\\r\\n$ git checkout " + BRANCH + " (existing local)\\r\\n"); + log("checked out existing local branch " + BRANCH); + } catch (_e) { + gitSync(["checkout", "-b", BRANCH]); + broadcastChunk("setup", "\\r\\n$ git checkout -b " + BRANCH + " (new local)\\r\\n"); + log("created local branch " + BRANCH + " off default"); + } } } catch (e) { - log("git branch setup failed:", e.message); + log("git branch setup failed:", e.message, e.stderr || ""); broadcastChunk("setup", "\\r\\nWarning: could not set up branch " + BRANCH + "\\r\\n"); } if (!PM) { - setupDone = true; - emitBranchStatus(); - watchGitDir(); log("setup complete (clone only, no package manager)"); + finish(false); return; } // Run install in the same "setup" stream const pmConfig = PM_CONFIG[PM]; - if (!pmConfig) { setupDone = true; emitBranchStatus(); watchGitDir(); return; } + if (!pmConfig) { + finish(false); + return; + } const corepackSetup = "export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 && corepack enable && "; - const installCmd = PATH_PREFIX + "cd /app && " + corepackSetup + pmConfig.install; + const installCmd = PATH_PREFIX + "cd " + APP_ROOT + " && " + corepackSetup + pmConfig.install; const installLabel = "$ " + pmConfig.install; broadcastChunk("setup", "\\r\\n" + installLabel + "\\r\\n"); @@ -450,18 +511,71 @@ function runSetup() { log("spawned setup (install) pid=" + installChild.pid); installChild.stdout.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); installChild.stderr.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); - installChild.on("close", (installCode) => { - log("install exited code=" + installCode); + installChild.on("error", (err) => { + log("spawn error install", err && err.message ? err.message : String(err)); + broadcastChunk("setup", "\\r\\nSpawn failed: " + ((err && err.message) || err) + "\\r\\n"); + setupRunning = false; setupDone = true; emitBranchStatus(); watchGitDir(); + }); + installChild.on("close", (installCode) => { + log("install exited code=" + installCode); if (installCode === 0) { - log("setup complete, discovering scripts"); - discoverScripts(); + finish(true); } else { + setupRunning = false; + setupDone = true; + emitBranchStatus(); + watchGitDir(); broadcastChunk("setup", "\\r\\nInstall failed with exit code " + installCode + "\\r\\n"); } }); + } + + // Resume-on-restart: if /app/.git already exists, the repo is cloned and + // we jump straight to the post-clone logic. Without this, git clone into + // a non-empty directory fails and setup hangs forever after a daemon + // crash during a previous setup run. + let gitDirExists = false; + try { + gitDirExists = fs.existsSync(APP_ROOT + "/.git"); + } catch (_e) { + gitDirExists = false; + } + if (gitDirExists) { + log("resuming setup: " + APP_ROOT + "/.git exists, skipping clone"); + broadcastChunk("setup", "$ (resuming setup; " + APP_ROOT + " already cloned)\\r\\n"); + proceedAfterClone(); + return; + } + + const cloneCmd = "git clone --depth 1 " + CLONE_URL + " " + APP_ROOT; + const cloneLabel = "$ git clone --depth 1 " + REPO_NAME + " " + APP_ROOT; + broadcastChunk("setup", cloneLabel + "\\r\\n"); + + const child = spawn("script", ["-q", "-c", cloneCmd, "/dev/null"], { + stdio: ["ignore", "pipe", "pipe"], + uid: DECO_UID, + gid: DECO_GID, + env: DECO_ENV, + }); + log("spawned setup (clone) pid=" + child.pid); + child.stdout.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); + child.stderr.on("data", (chunk) => broadcastChunk("setup", chunk.toString("utf-8"))); + child.on("error", (err) => { + log("spawn error clone", err && err.message ? err.message : String(err)); + broadcastChunk("setup", "\\r\\nSpawn failed: " + ((err && err.message) || err) + "\\r\\n"); + setupRunning = false; + }); + child.on("close", (code) => { + log("clone exited code=" + code); + if (code !== 0) { + setupRunning = false; + broadcastChunk("setup", "\\r\\nClone failed with exit code " + code + "\\r\\n"); + return; + } + proceedAfterClone(); }); } @@ -471,27 +585,25 @@ const FAST_PROBE_MS = 3000; const SLOW_PROBE_MS = 30000; const FAST_PROBE_LIMIT = 20; -function probeUpstream() { +async function probeUpstream() { const prevReady = lastStatus.ready; - const req = http.request( - { hostname: UPSTREAM_HOST, port: UPSTREAM, path: "/", method: "HEAD", timeout: 5000 }, - (res) => { - const ct = (res.headers["content-type"] || "").toLowerCase(); - lastStatus = { - ready: res.statusCode >= 200 && res.statusCode < 400, - htmlSupport: ct.includes("text/html"), - }; - if (lastStatus.ready !== prevReady) { - log("upstream", lastStatus.ready ? "UP" : "DOWN", "status=" + res.statusCode); - } + try { + const res = await fetch( + "http://" + UPSTREAM_HOST + ":" + UPSTREAM + "/", + { method: "HEAD", signal: AbortSignal.timeout(5000) }, + ); + const ct = (res.headers.get("content-type") || "").toLowerCase(); + lastStatus = { + ready: res.status >= 200 && res.status < 400, + htmlSupport: ct.includes("text/html"), + }; + if (lastStatus.ready !== prevReady) { + log("upstream", lastStatus.ready ? "UP" : "DOWN", "status=" + res.status); } - ); - req.on("error", () => { - if (prevReady) log("upstream DOWN (error)"); + } catch (e) { + if (prevReady) log("upstream DOWN (error)", e.message || String(e)); lastStatus = { ready: false, htmlSupport: false }; - }); - req.on("timeout", () => { req.destroy(); }); - req.end(); + } probeCount++; const nextDelay = probeCount < FAST_PROBE_LIMIT ? FAST_PROBE_MS : SLOW_PROBE_MS; @@ -500,375 +612,488 @@ function probeUpstream() { setTimeout(probeUpstream, 1000); -// --- File operation handlers --- +// --- File operation handlers (Bun-native: Request → Response) --- -async function handleRead(req, res) { - try { - const body = await parseJsonBody(req); - const filePath = safePath(body.path || ""); - if (!filePath) return jsonResponse(res, 400, { error: "Path escapes /app" }); +async function handleRead(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } - let stat; - try { stat = fs.statSync(filePath); } catch { return jsonResponse(res, 400, { error: "File not found: " + body.path }); } - if (stat.isDirectory()) return jsonResponse(res, 400, { error: "Path is a directory" }); + const filePath = safePath(body.path || ""); + if (!filePath) return jsonResponse({ error: "Path escapes /app" }, 400); - // Binary detection: check first 8KB for null bytes - const fd = fs.openSync(filePath, "r"); - const probe = Buffer.alloc(Math.min(8192, stat.size)); - fs.readSync(fd, probe, 0, probe.length, 0); - fs.closeSync(fd); - if (probe.includes(0)) return jsonResponse(res, 400, { error: "File appears to be binary" }); + let stat; + try { stat = fs.statSync(filePath); } + catch { return jsonResponse({ error: "File not found: " + body.path }, 400); } + if (stat.isDirectory()) return jsonResponse({ error: "Path is a directory" }, 400); + // Binary detection: check first 8KB for null bytes + const fd = fs.openSync(filePath, "r"); + const probe = Buffer.alloc(Math.min(8192, stat.size)); + fs.readSync(fd, probe, 0, probe.length, 0); + fs.closeSync(fd); + if (probe.includes(0)) return jsonResponse({ error: "File appears to be binary" }, 400); + + try { const raw = fs.readFileSync(filePath, "utf-8"); const lines = raw.split("\\n"); const offset = Math.max(1, body.offset || 1); const limit = body.limit || 2000; const slice = lines.slice(offset - 1, offset - 1 + limit); const numbered = slice.map((line, i) => (offset + i) + "\\t" + line).join("\\n"); - jsonResponse(res, 200, { content: numbered, lineCount: lines.length }); + return jsonResponse({ content: numbered, lineCount: lines.length }); } catch (e) { - jsonResponse(res, 500, { error: e.message }); + return jsonResponse({ error: e.message }, 500); } } -async function handleWrite(req, res) { - try { - const body = await parseJsonBody(req); - if (typeof body.content !== "string") return jsonResponse(res, 400, { error: "content is required" }); - const filePath = safePath(body.path || ""); - if (!filePath) return jsonResponse(res, 400, { error: "Path escapes /app" }); +async function handleWrite(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } + + if (typeof body.content !== "string") return jsonResponse({ error: "content is required" }, 400); + const filePath = safePath(body.path || ""); + if (!filePath) return jsonResponse({ error: "Path escapes /app" }, 400); + try { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, body.content, "utf-8"); - jsonResponse(res, 200, { ok: true, bytesWritten: Buffer.byteLength(body.content, "utf-8") }); + return jsonResponse({ ok: true, bytesWritten: Buffer.byteLength(body.content, "utf-8") }); } catch (e) { - jsonResponse(res, 500, { error: e.message }); + return jsonResponse({ error: e.message }, 500); } } -async function handleEdit(req, res) { - try { - const body = await parseJsonBody(req); - const filePath = safePath(body.path || ""); - if (!filePath) return jsonResponse(res, 400, { error: "Path escapes /app" }); - if (!body.old_string || typeof body.old_string !== "string") return jsonResponse(res, 400, { error: "old_string is required" }); - if (typeof body.new_string !== "string") return jsonResponse(res, 400, { error: "new_string is required" }); - if (body.old_string === body.new_string) return jsonResponse(res, 400, { error: "old_string and new_string must differ" }); +async function handleEdit(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } + + const filePath = safePath(body.path || ""); + if (!filePath) return jsonResponse({ error: "Path escapes /app" }, 400); + if (!body.old_string || typeof body.old_string !== "string") return jsonResponse({ error: "old_string is required" }, 400); + if (typeof body.new_string !== "string") return jsonResponse({ error: "new_string is required" }, 400); + if (body.old_string === body.new_string) return jsonResponse({ error: "old_string and new_string must differ" }, 400); - let content; - try { content = fs.readFileSync(filePath, "utf-8"); } catch { return jsonResponse(res, 400, { error: "File not found: " + body.path }); } + let content; + try { content = fs.readFileSync(filePath, "utf-8"); } + catch { return jsonResponse({ error: "File not found: " + body.path }, 400); } - const replaceAll = body.replace_all === true; - const count = content.split(body.old_string).length - 1; - if (count === 0) return jsonResponse(res, 400, { error: "old_string not found in file" }); - if (!replaceAll && count > 1) return jsonResponse(res, 400, { error: "old_string found " + count + " times. Use replace_all or provide more context to make it unique." }); + const replaceAll = body.replace_all === true; + const count = content.split(body.old_string).length - 1; + if (count === 0) return jsonResponse({ error: "old_string not found in file" }, 400); + if (!replaceAll && count > 1) return jsonResponse({ error: "old_string found " + count + " times. Use replace_all or provide more context to make it unique." }, 400); + try { const updated = replaceAll ? content.replaceAll(body.old_string, body.new_string) : content.replace(body.old_string, body.new_string); fs.writeFileSync(filePath, updated, "utf-8"); - jsonResponse(res, 200, { ok: true, replacements: replaceAll ? count : 1 }); + return jsonResponse({ ok: true, replacements: replaceAll ? count : 1 }); } catch (e) { - jsonResponse(res, 500, { error: e.message }); + return jsonResponse({ error: e.message }, 500); } } -async function handleGrep(req, res) { - try { - const body = await parseJsonBody(req); - if (!body.pattern) return jsonResponse(res, 400, { error: "pattern is required" }); - - const searchPath = body.path ? safePath(body.path) : APP_ROOT; - if (!searchPath) return jsonResponse(res, 400, { error: "Path escapes /app" }); - - const args = []; - const mode = body.output_mode || "files"; - if (mode === "files") args.push("--files-with-matches"); - else if (mode === "count") args.push("--count"); - else args.push("--line-number"); - - if (body.ignore_case) args.push("-i"); - if (body.context && mode === "content") args.push("-C", String(body.context)); - if (body.glob) args.push("--glob", body.glob); - args.push("--", body.pattern, searchPath); - - const limit = body.limit || 250; - const child = spawn("rg", args, { cwd: APP_ROOT, stdio: ["ignore", "pipe", "pipe"], uid: DECO_UID, gid: DECO_GID }); - let stdout = ""; - let lineCount = 0; - child.stdout.on("data", (chunk) => { - const text = chunk.toString("utf-8"); - const lines = text.split("\\n"); - for (const line of lines) { - if (lineCount >= limit) break; - if (line) { stdout += (stdout ? "\\n" : "") + line; lineCount++; } +async function handleGrep(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } + + if (!body.pattern) return jsonResponse({ error: "pattern is required" }, 400); + + const searchPath = body.path ? safePath(body.path) : APP_ROOT; + if (!searchPath) return jsonResponse({ error: "Path escapes /app" }, 400); + + const args = []; + const mode = body.output_mode || "files"; + if (mode === "files") args.push("--files-with-matches"); + else if (mode === "count") args.push("--count"); + else args.push("--line-number"); + + if (body.ignore_case) args.push("-i"); + if (body.context && mode === "content") args.push("-C", String(body.context)); + if (body.glob) args.push("--glob", body.glob); + args.push("--", body.pattern, searchPath); + + const limit = body.limit || 250; + const child = spawn("rg", args, { cwd: APP_ROOT, stdio: ["ignore", "pipe", "pipe"], uid: DECO_UID, gid: DECO_GID }); + let stdout = ""; + let lineCount = 0; + let truncated = false; + child.stdout.on("data", (chunk) => { + if (truncated) return; + const text = chunk.toString("utf-8"); + const lines = text.split("\\n"); + for (const line of lines) { + if (lineCount >= limit) { + // Kill rg once we've collected enough — otherwise rg keeps emitting + // to stdout until the OS pipe buffer fills and it blocks in write(), + // and the daemon's close-promise never resolves. + truncated = true; + try { child.kill("SIGTERM"); } catch (e) {} + break; } + if (line) { stdout += (stdout ? "\\n" : "") + line; lineCount++; } + } + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); + + const code = await new Promise((resolve) => { + child.on("close", (c) => resolve(c)); + child.on("error", (err) => { + log("spawn error grep", err && err.message ? err.message : String(err)); + resolve(-1); }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); - child.on("close", (code) => { - // rg exits 1 when no matches found — not an error - if (code > 1) return jsonResponse(res, 500, { error: stderr || "rg failed with code " + code }); - jsonResponse(res, 200, { results: stdout, matchCount: lineCount }); - }); - } catch (e) { - jsonResponse(res, 500, { error: e.message }); - } + }); + // rg exits 1 when no matches found — not an error. When we SIGTERM above, + // code is typically null (signal termination) which satisfies !(code > 1) + // and falls through to the success response with the already-collected + // results. + if (code !== null && code > 1) return jsonResponse({ error: stderr || "rg failed with code " + code }, 500); + return jsonResponse({ results: stdout, matchCount: lineCount }); } -async function handleGlob(req, res) { - try { - const body = await parseJsonBody(req); - if (!body.pattern) return jsonResponse(res, 400, { error: "pattern is required" }); +async function handleGlob(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } - const searchPath = body.path ? safePath(body.path) : APP_ROOT; - if (!searchPath) return jsonResponse(res, 400, { error: "Path escapes /app" }); + if (!body.pattern) return jsonResponse({ error: "pattern is required" }, 400); - const child = spawn("rg", ["--files", "--glob", body.pattern, searchPath], { cwd: APP_ROOT, stdio: ["ignore", "pipe", "pipe"], uid: DECO_UID, gid: DECO_GID }); - let stdout = ""; - child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); - child.on("close", (code) => { - if (code > 1) return jsonResponse(res, 500, { error: stderr || "rg failed with code " + code }); - const files = stdout.split("\\n").filter(Boolean).slice(0, 1000).map(f => { - return f.startsWith(APP_ROOT + "/") ? f.slice(APP_ROOT.length + 1) : f; - }); - jsonResponse(res, 200, { files: files }); + const searchPath = body.path ? safePath(body.path) : APP_ROOT; + if (!searchPath) return jsonResponse({ error: "Path escapes /app" }, 400); + + const child = spawn("rg", ["--files", "--glob", body.pattern, searchPath], { cwd: APP_ROOT, stdio: ["ignore", "pipe", "pipe"], uid: DECO_UID, gid: DECO_GID }); + let stdout = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); + + const code = await new Promise((resolve) => { + child.on("close", (c) => resolve(c)); + child.on("error", (err) => { + log("spawn error glob", err && err.message ? err.message : String(err)); + resolve(-1); }); - } catch (e) { - jsonResponse(res, 500, { error: e.message }); - } + }); + if (code !== null && code > 1) return jsonResponse({ error: stderr || "rg failed with code " + code }, 500); + const files = stdout.split("\\n").filter(Boolean).slice(0, 1000).map(f => { + return f.startsWith(APP_ROOT + "/") ? f.slice(APP_ROOT.length + 1) : f; + }); + return jsonResponse({ files: files }); } -async function handleBash(req, res) { - try { - const body = await parseJsonBody(req); - if (!body.command || typeof body.command !== "string") return jsonResponse(res, 400, { error: "command is required" }); +async function handleBash(req) { + let body; + try { body = await parseBase64JsonBody(req); } + catch (e) { return jsonResponse({ error: e.message }, 400); } - const timeout = Math.min(body.timeout || 30000, 120000); - const child = spawn("bash", ["-c", body.command], { - cwd: APP_ROOT, - stdio: ["ignore", "pipe", "pipe"], - uid: DECO_UID, - gid: DECO_GID, - env: DECO_ENV, - }); + if (!body.command || typeof body.command !== "string") + return jsonResponse({ error: "command is required" }, 400); - let stdout = ""; - let stderr = ""; - let killed = false; - child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); }); - child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); + const timeout = Math.min(body.timeout || 30000, 120000); + const child = spawn("bash", ["-c", body.command], { + cwd: APP_ROOT, + stdio: ["ignore", "pipe", "pipe"], + uid: DECO_UID, + gid: DECO_GID, + env: DECO_ENV, + }); - const timer = setTimeout(() => { - killed = true; - try { child.kill("SIGKILL"); } catch (e) {} - }, timeout); + let stdout = ""; + let stderr = ""; + let killed = false; + child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); }); + const timer = setTimeout(() => { + killed = true; + try { child.kill("SIGKILL"); } catch (e) {} + }, timeout); + + const exitCode = await new Promise((resolve) => { child.on("close", (code) => { clearTimeout(timer); - jsonResponse(res, 200, { stdout: stdout, stderr: stderr, exitCode: killed ? -1 : (code ?? 1) }); + resolve(killed ? -1 : (code ?? 1)); }); - } catch (e) { - jsonResponse(res, 500, { error: e.message }); - } + child.on("error", (err) => { + clearTimeout(timer); + log("spawn error bash", err && err.message ? err.message : String(err)); + // Surface the error in stderr so the caller has context. + stderr += (stderr ? "\\n" : "") + "spawn error: " + (err && err.message ? err.message : String(err)); + resolve(-1); + }); + }); + return jsonResponse({ stdout: stdout, stderr: stderr, exitCode: exitCode }); } -// --- HTTP server --- -http.createServer(async (req, res) => { - if (!req.url.startsWith("/_decopilot_vm/")) { - log("proxy", req.method, req.url); - } - - // Bearer required on every /_decopilot_vm/* route except CORS preflight. - // Reverse-proxy path below stays public (iframe needs unauth dev-server). - if ( - req.url.startsWith("/_decopilot_vm/") && - req.method !== "OPTIONS" && - !authorized(req) - ) { - res.writeHead(401, { +// --- Bun-native helpers --- +function jsonResponse(body, status) { + return new Response(JSON.stringify(body), { + status: status ?? 200, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", + }, + }); +} + +function handleEvents() { + if (sseClients.size >= MAX_SSE_CLIENTS) { + log("SSE rejected (max clients)"); + return new Response("Too many connections", { + status: 429, + headers: { "Access-Control-Allow-Origin": "*" }, }); - res.end(JSON.stringify({ error: "unauthorized" })); - return; } + let controller; + let keepAliveTimer; + const stream = new ReadableStream({ + start(c) { + controller = c; + // Replay state first — enqueue snapshots BEFORE adding this controller + // to sseClients so concurrent broadcastChunk / broadcastEvent calls + // don't interleave live output inside the replay window. Without the + // ordering, a live log line can land between the replay-status and + // replay-log frames and the client sees events out of order (and may + // see the same line twice — once in the log replay buffer, once live). + c.enqueue(sseFormat("status", JSON.stringify({ type: "status", ...lastStatus }))); + for (const source of Object.keys(replayBuffers)) { + const buf = replayBuffers[source]; + if (buf && buf.length > 0) { + c.enqueue(sseFormat("log", JSON.stringify({ source: source, data: buf }))); + } + } + if (discoveredScripts) { + c.enqueue(sseFormat("scripts", JSON.stringify({ type: "scripts", scripts: discoveredScripts }))); + } + const active = Object.keys(children).filter((k) => children[k] !== null); + c.enqueue(sseFormat("processes", JSON.stringify({ type: "processes", active: active }))); + if (lastBranchStatus) { + c.enqueue(sseFormat("branch-status", JSON.stringify(Object.assign({ type: "branch-status" }, lastBranchStatus)))); + } - // SSE endpoint - if (req.url === "/_decopilot_vm/events" && req.method === "GET") { - if (sseClients.size >= MAX_SSE_CLIENTS) { - log("SSE rejected (max clients)"); - res.writeHead(429, { "Access-Control-Allow-Origin": "*" }); - res.end("Too many connections"); - return; - } - res.writeHead(200, { + // NOW register for live broadcasts. The log() below also broadcasts, + // but the controller receives it only as a fresh live event (not as + // part of the replay buffer we just flushed). + sseClients.add(c); + log("SSE connect, clients=" + sseClients.size); + + keepAliveTimer = setInterval(() => { + try { + c.enqueue(sseFormat("status", JSON.stringify({ type: "status", ...lastStatus }))); + } catch (e) { + clearInterval(keepAliveTimer); + sseClients.delete(c); + } + }, 15000); + }, + cancel() { + clearInterval(keepAliveTimer); + sseClients.delete(controller); + log("SSE disconnect, clients=" + sseClients.size); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", - }); - // 1. Replay status - res.write("event: status\\ndata: " + JSON.stringify({ type: "status", ...lastStatus }) + "\\n\\n"); - // 2. Replay log buffers - for (const source of Object.keys(replayBuffers)) { - const buf = replayBuffers[source]; - if (buf && buf.length > 0) { - const payload = JSON.stringify({ source: source, data: buf }); - res.write("event: log\\ndata: " + payload + "\\n\\n"); - } - } - // 3. Replay discovered scripts - if (discoveredScripts) { - res.write("event: scripts\\ndata: " + JSON.stringify({ type: "scripts", scripts: discoveredScripts }) + "\\n\\n"); - } - // 4. Replay active processes - const active = Object.keys(children).filter(k => children[k] !== null); - res.write("event: processes\\ndata: " + JSON.stringify({ type: "processes", active: active }) + "\\n\\n"); + "X-Accel-Buffering": "no", + "Content-Encoding": "identity", + }, + }); +} - // 5. Replay last branch-status - if (lastBranchStatus) { - res.write("event: branch-status\\ndata: " + JSON.stringify(Object.assign({ type: "branch-status" }, lastBranchStatus)) + "\\n\\n"); +function handleExec(name) { + if (!name) return jsonResponse({ error: "missing script name" }, 400); + if (name === "setup") { + if (setupRunning) { + log("exec setup rejected: setup already running"); + return jsonResponse({ error: "setup already running" }, 409); } - - sseClients.add(res); - log("SSE connect, clients=" + sseClients.size); - req.on("close", () => { sseClients.delete(res); log("SSE disconnect, clients=" + sseClients.size); }); - const ka = setInterval(() => { - if (!res.writable) { clearInterval(ka); sseClients.delete(res); return; } - res.write("event: status\\ndata: " + JSON.stringify({ type: "status", ...lastStatus }) + "\\n\\n"); - }, 15000); - req.on("close", () => { clearInterval(ka); }); - return; + log("exec setup"); + runSetup(); + return jsonResponse({ ok: true }); } + if (!PM || !setupDone) { + log("exec rejected: setup not done or no package manager"); + return jsonResponse({ error: "setup not complete" }, 400); + } + const pmConfig = PM_CONFIG[PM]; + if (!pmConfig) return jsonResponse({ error: "unknown package manager" }, 400); + const cmd = + PATH_PREFIX + + "cd /app && HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT=" + + PORT + + " " + + pmConfig.runPrefix + + " " + + name; + const label = "$ " + pmConfig.runPrefix + " " + name; + log("exec", name); + runProcess(name, cmd, label); + return jsonResponse({ ok: true }); +} - // File operation endpoints - if (req.method === "POST" && req.url === "/_decopilot_vm/read") return handleRead(req, res); - if (req.method === "POST" && req.url === "/_decopilot_vm/write") return handleWrite(req, res); - if (req.method === "POST" && req.url === "/_decopilot_vm/edit") return handleEdit(req, res); - if (req.method === "POST" && req.url === "/_decopilot_vm/grep") return handleGrep(req, res); - if (req.method === "POST" && req.url === "/_decopilot_vm/glob") return handleGlob(req, res); - if (req.method === "POST" && req.url === "/_decopilot_vm/bash") return handleBash(req, res); - - // Exec endpoint — run any script by name - if (req.method === "POST" && req.url.startsWith("/_decopilot_vm/exec/")) { - const name = req.url.slice("/_decopilot_vm/exec/".length); - if (!name) { - res.writeHead(400, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ error: "missing script name" })); - return; - } - if (name === "setup") { - log("exec setup"); - runSetup(); - res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ ok: true })); - return; +function handleKill(name) { + if (!name) return jsonResponse({ error: "missing script name" }, 400); + const child = children[name]; + if (!child) return jsonResponse({ error: "not running" }, 400); + try { child.kill("SIGTERM"); } catch (e) {} + // Escalate to SIGKILL if the child ignores SIGTERM (buggy runners, + // synchronous loops, etc.) so a stuck process can't block subsequent + // runProcess calls that rely on children[source] being nulled out. + const killer = setTimeout(() => { + if (children[name] === child) { + log("SIGKILL escalation", name); + try { child.kill("SIGKILL"); } catch (e) {} } - if (!PM || !setupDone) { - log("exec rejected: setup not done or no package manager"); - res.writeHead(400, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ error: "setup not complete" })); - return; + }, 3000); + child.on("close", () => clearTimeout(killer)); + return jsonResponse({ ok: true }); +} + +async function handleProxy(req) { + const url = new URL(req.url); + const target = + "http://" + UPSTREAM_HOST + ":" + UPSTREAM + url.pathname + url.search; + + const outHeaders = new Headers(req.headers); + outHeaders.delete("accept-encoding"); + outHeaders.delete("host"); + outHeaders.delete("transfer-encoding"); + outHeaders.delete("content-length"); + + let upstream; + try { + const init = { method: req.method, headers: outHeaders, redirect: "manual", signal: AbortSignal.timeout(60000) }; + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = await req.arrayBuffer(); } - const pmConfig = PM_CONFIG[PM]; - if (!pmConfig) { - res.writeHead(400, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ error: "unknown package manager" })); - return; + upstream = await fetch(target, init); + } catch (e) { + log("proxy error", req.method, url.pathname, e.message || String(e)); + const msg = (e.message || String(e)); + const connErr = /ECONNREFUSED|ECONNRESET|ECONNABORTED|fetch failed|Unable to connect|TimeoutError|timed out/i.test(msg); + if (url.pathname === "/" && connErr) { + return new Response( + 'Starting...

Server is starting\\u2026

This page will refresh automatically.

', + { + status: 503, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Retry-After": "1", + "Access-Control-Allow-Origin": "*", + }, + }, + ); } - const cmd = PATH_PREFIX + "cd /app && HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT=" + PORT + " " + pmConfig.runPrefix + " " + name; - const label = "$ " + pmConfig.runPrefix + " " + name; - log("exec", name); - runProcess(name, cmd, label); - res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ ok: true })); - return; + return jsonResponse({ error: "proxy error: " + msg }, 502); } - // Kill endpoint - if (req.method === "POST" && req.url.startsWith("/_decopilot_vm/kill/")) { - const name = req.url.slice("/_decopilot_vm/kill/".length); - if (children[name]) { - log("kill", name, "pid=" + children[name].pid); - try { children[name].kill("SIGKILL"); } catch (e) {} - children[name] = null; - broadcastEvent("processes", { type: "processes", active: Object.keys(children).filter(k => children[k] !== null) }); + const respHeaders = new Headers(upstream.headers); + respHeaders.delete("x-frame-options"); + respHeaders.delete("content-security-policy"); + respHeaders.delete("content-encoding"); + + const ct = (upstream.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("text/html")) { + respHeaders.delete("content-length"); + let html = await upstream.text(); + const idx = html.lastIndexOf(""); + if (idx !== -1) { + html = html.slice(0, idx) + BOOTSTRAP + html.slice(idx); } else { - log("kill", name, "(no process running)"); + html += BOOTSTRAP; } - res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ ok: true })); - return; + return new Response(html, { status: upstream.status, headers: respHeaders }); } + return new Response(upstream.body, { + status: upstream.status, + headers: respHeaders, + }); +} - // Scripts endpoint (fallback for missed SSE) - if (req.method === "GET" && req.url === "/_decopilot_vm/scripts") { - res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); - res.end(JSON.stringify({ scripts: discoveredScripts || [] })); - return; - } +// --- HTTP server --- +Bun.serve({ + port: PROXY_PORT, + hostname: "0.0.0.0", + // SSE streams can run a long time; disable the default idle timeout. + idleTimeout: 0, + async fetch(req) { + const url = new URL(req.url); + const pathname = url.pathname; + + if (!pathname.startsWith("/_decopilot_vm/")) { + log("proxy", req.method, pathname); + } - // CORS preflight - if (req.method === "OPTIONS" && req.url.startsWith("/_decopilot_vm/")) { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST", - "Access-Control-Allow-Headers": "Content-Type, Accept, Cache-Control, Authorization", - }); - res.end(); - return; - } + // Bearer required on every /_decopilot_vm/* route except CORS preflight. + // Reverse-proxy path below stays public (iframe needs unauth dev-server). + if ( + pathname.startsWith("/_decopilot_vm/") && + req.method !== "OPTIONS" && + !authorized(req) + ) { + return jsonResponse({ error: "unauthorized" }, 401); + } - // Catch-all for unmatched /_decopilot_vm/ routes — return 404 with CORS - if (req.url.startsWith("/_decopilot_vm/")) { - log("unmatched daemon route", req.method, req.url); - jsonResponse(res, 404, { error: "Not found: " + req.url }); - return; - } + // --- SSE endpoint (Bun-native) --- + if (pathname === "/_decopilot_vm/events" && req.method === "GET") { + return handleEvents(); + } - // Reverse proxy to upstream - const hdrs = Object.assign({}, req.headers); - delete hdrs["accept-encoding"]; - const opts = { hostname: UPSTREAM_HOST, port: UPSTREAM, path: req.url, method: req.method, headers: hdrs }; - const p = http.request(opts, (upstream) => { - delete upstream.headers["x-frame-options"]; - delete upstream.headers["content-security-policy"]; - delete upstream.headers["content-encoding"]; - const ct = (upstream.headers["content-type"] || "").toLowerCase(); - if (ct.includes("text/html")) { - delete upstream.headers["content-length"]; - res.writeHead(upstream.statusCode, upstream.headers); - const chunks = []; - upstream.on("data", (c) => chunks.push(c)); - upstream.on("end", () => { - let html = Buffer.concat(chunks).toString("utf-8"); - const idx = html.lastIndexOf(""); - if (idx !== -1) { - html = html.slice(0, idx) + BOOTSTRAP + html.slice(idx); - } else { - html += BOOTSTRAP; - } - res.end(html); + // --- File operations (Bun-native Request → Response) --- + if (req.method === "POST" && pathname === "/_decopilot_vm/read") + return handleRead(req); + if (req.method === "POST" && pathname === "/_decopilot_vm/write") + return handleWrite(req); + if (req.method === "POST" && pathname === "/_decopilot_vm/edit") + return handleEdit(req); + if (req.method === "POST" && pathname === "/_decopilot_vm/grep") + return handleGrep(req); + if (req.method === "POST" && pathname === "/_decopilot_vm/glob") + return handleGlob(req); + if (req.method === "POST" && pathname === "/_decopilot_vm/bash") + return handleBash(req); + + // --- Exec/kill --- + if (req.method === "POST" && pathname.startsWith("/_decopilot_vm/exec/")) { + return handleExec(pathname.slice("/_decopilot_vm/exec/".length)); + } + if (req.method === "POST" && pathname.startsWith("/_decopilot_vm/kill/")) { + return handleKill(pathname.slice("/_decopilot_vm/kill/".length)); + } + + // --- Scripts + OPTIONS + catch-all under /_decopilot_vm/ --- + if (req.method === "GET" && pathname === "/_decopilot_vm/scripts") { + return jsonResponse({ scripts: discoveredScripts || [] }); + } + if (req.method === "OPTIONS" && pathname.startsWith("/_decopilot_vm/")) { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "Content-Type, Accept, Cache-Control, Authorization", + }, }); - } else { - res.writeHead(upstream.statusCode, upstream.headers); - upstream.pipe(res); } - }); - p.on("error", (e) => { - log("proxy error", req.method, req.url, e.message); - const connErr = ["ECONNREFUSED", "ECONNRESET", "ECONNABORTED"].includes(e.code); - if (req.url === "/" && connErr) { - res.writeHead(503, { "Content-Type": "text/html; charset=utf-8", "Retry-After": "1", "Access-Control-Allow-Origin": "*" }); - res.end('Starting...

Server is starting\\u2026

This page will refresh automatically.

'); - return; + if (pathname.startsWith("/_decopilot_vm/")) { + log("unmatched daemon route", req.method, pathname); + return jsonResponse({ error: "Not found: " + pathname }, 404); } - jsonResponse(res, 502, { error: "proxy error: " + e.message }); - }); - req.pipe(p); -}).listen(PROXY_PORT, "0.0.0.0"); + + // --- Reverse proxy to upstream (Bun-native fetch) --- + return handleProxy(req); + }, +}); // Auto-start setup on daemon boot log("starting setup: cloning " + REPO_NAME); diff --git a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/runner.ts b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/runner.ts index fd1aa0ce9d..25eda1f986 100644 --- a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/runner.ts +++ b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/runner.ts @@ -472,11 +472,16 @@ export class FreestyleSandboxRunner implements SandboxRunner { }); const baseSpec = new VmSpec() .with("node", new VmNodeJs()) + .with("js", new VmBun()) .additionalFiles({ "/opt/daemon.js": { content: daemonScript }, "/opt/run-daemon.sh": { + // Source nvm so node + corepack are on PATH for child processes + // (corepack enable is needed before bun install / pnpm install / + // yarn install; npm projects need node itself). Bun is the daemon + // runtime but child processes inherit whatever PATH we set here. content: - "#!/bin/bash\nsource /etc/profile.d/nvm.sh\nexec node /opt/daemon.js\n", + "#!/bin/bash\nsource /etc/profile.d/nvm.sh 2>/dev/null || true\nexec /opt/bun/bin/bun /opt/daemon.js\n", }, "/opt/install-ripgrep.sh": { content: @@ -505,22 +510,20 @@ export class FreestyleSandboxRunner implements SandboxRunner { exec: ["/bin/bash /opt/run-daemon.sh"], after: [ "install-nodejs.service", + "install-bun.service", "install-ripgrep.service", "prepare-app-dir.service", ], requires: [ "install-nodejs.service", + "install-bun.service", "install-ripgrep.service", "prepare-app-dir.service", ], wantedBy: ["multi-user.target"], restartPolicy: { policy: "always", restartSec: 2 }, }); - return runtime === "deno" - ? baseSpec.with("deno", new VmDeno()) - : runtime === "bun" - ? baseSpec.with("js", new VmBun()) - : baseSpec; + return runtime === "deno" ? baseSpec.with("deno", new VmDeno()) : baseSpec; } /** Same base64 scheme as `proxyDaemonRequest` — parity with exec path. */ From b781074aa695c83cf26a0066334b618be7d0e2d2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 24 Apr 2026 13:34:10 -0300 Subject: [PATCH 2/6] [chore]: sync bun.lock with apps/mesh version + plugin optionalDependencies Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 23e31d058b..9daf838973 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,7 @@ }, "apps/mesh": { "name": "decocms", - "version": "2.272.5", + "version": "2.274.0", "bin": { "deco": "./dist/server/cli.js", }, @@ -229,6 +229,12 @@ "@types/bun": "latest", "typescript": "^5.8.3", }, + "optionalDependencies": { + "@freestyle-sh/with-bun": "^0.2.12", + "@freestyle-sh/with-deno": "^0.0.4", + "@freestyle-sh/with-nodejs": "^0.2.9", + "freestyle-sandboxes": "^0.1.46", + }, }, "packages/mesh-plugin-workflows": { "name": "mesh-plugin-workflows", From 6d0c649aa6e5580473a447b7a5ce7216daad52cb Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 24 Apr 2026 13:43:33 -0300 Subject: [PATCH 3/6] fix(vm/daemon): de-flake e2e tests on CI, install ripgrep in workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two exec/setup tests raced against the daemon's auto-boot runSetup() — the clone to invalid.example.com keeps setupRunning=true for tens of milliseconds, long enough that on CI both the "returns 200" and "returns [200, 409]" tests saw 409 on every call. Strip the boot runSetup() out of the generated script in the test fixture so tests drive setup explicitly and the Bun.serve handler ordering makes the concurrent race deterministic. The grep/glob test spawns rg, which isn't on bare Ubuntu runners — install ripgrep in the test workflow and guard the test with it.skipIf(!hasRipgrep) so dev machines without it don't fail. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 3 + .../freestyle/daemon-script.e2e.test.ts | 201 ++++++++++-------- 2 files changed, 115 insertions(+), 89 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 020d5e46f1..8297a29673 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,9 @@ jobs: with: bun-version: "1.3.5" + - name: Install ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep + - name: Install dependencies run: bun install diff --git a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts index 585b3a1b9d..10c1537ad7 100644 --- a/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts +++ b/packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts @@ -11,17 +11,40 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { buildDaemonScript } from "./daemon-script"; const DAEMON_TOKEN = "t".repeat(32); +// Ripgrep isn't always installed on bare CI runners or dev machines; skip +// the rg-dependent test when it's missing so the rest of the suite still +// runs. CI installs rg in the workflow so this guard stays false there. +const hasRipgrep = spawnSync("which", ["rg"]).status === 0; + function authHeaders( extra: Record = {}, ): Record { return { Authorization: `Bearer ${DAEMON_TOKEN}`, ...extra }; } +/** + * Rewrite the generated daemon script for out-of-VM execution: + * - strip uid/gid=1000 from spawn options (we're not root in tests) + * - retarget APP_ROOT to a per-test temp dir + * - drop the auto-boot runSetup() call so tests drive setup explicitly; + * otherwise the boot clone to invalid.example.com leaves setupRunning + * flapping and races the exec/setup tests + */ +function patchForTest(script: string, appRootDir: string): string { + return script + .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") + .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appRootDir}";`) + .replace( + /log\("starting setup: cloning " \+ REPO_NAME\);\s*runSetup\(\);/, + "", + ); +} + let daemonProc: ChildProcess | null = null; let daemonPort = 0; let appDir = ""; @@ -53,26 +76,23 @@ function freePort(): number { async function startDaemon() { appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); daemonPort = freePort(); - const script = buildDaemonScript({ - upstreamPort: "3000", - packageManager: null, - pathPrefix: "", - port: "3000", - cloneUrl: "https://invalid.example.com/no-op.git", - repoName: "test/repo", - proxyPort: daemonPort, - bootstrapScript: "", - gitUserName: "test", - gitUserEmail: "t@e", - branch: "main", - daemonToken: DAEMON_TOKEN, - }) - // Strip uid/gid so spawn works outside the VM (we're not root here). - // Match the full pair including the leading comma so we don't leave - // dangling commas or lone gid when uid/gid appear right before `}`. - .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") - // Use our temp dir for APP_ROOT - .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + const script = patchForTest( + buildDaemonScript({ + upstreamPort: "3000", + packageManager: null, + pathPrefix: "", + port: "3000", + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }), + appDir, + ); const scriptPath = join(appDir, "daemon.js"); writeFileSync(scriptPath, script); @@ -221,7 +241,8 @@ describe("daemon e2e (runs generated script under Bun)", () => { ctrl.abort(); }); - it("POST /_decopilot_vm/exec/setup triggers re-setup and returns { ok: true }", async () => { + it("POST /_decopilot_vm/exec/setup returns { ok: true } when idle", async () => { + // Boot autostart is stripped by patchForTest so the daemon is idle here. const res = await fetch( `http://localhost:${daemonPort}/_decopilot_vm/exec/setup`, { method: "POST", headers: authHeaders() }, @@ -231,12 +252,9 @@ describe("daemon e2e (runs generated script under Bun)", () => { expect(body.ok).toBe(true); }); - it("POST /_decopilot_vm/exec/setup returns 409 when setup is already running", async () => { - // The daemon auto-triggers setup on boot; by the time this test runs - // the boot setup is typically still in-flight (clone fails fast against - // invalid.example.com but leaves a blocked setupRunning window around - // spawn events). Fire two POSTs back-to-back and expect exactly one - // 200 and one 409 — the re-entry guard rejects the concurrent call. + it("POST /_decopilot_vm/exec/setup concurrent calls return [200, 409]", async () => { + // Handler is sync through setupRunning=true, so the Bun.serve event loop + // serializes: first request wins, second hits the re-entry guard. const first = fetch( `http://localhost:${daemonPort}/_decopilot_vm/exec/setup`, { method: "POST", headers: authHeaders() }, @@ -270,38 +288,41 @@ describe("daemon e2e (runs generated script under Bun)", () => { expect(body.error).toContain("not running"); }); - it("POST /_decopilot_vm/grep and /_decopilot_vm/glob succeed (confirms uid/gid stripped from spawn)", async () => { - // Create a file in appDir to search - const sampleFile = join(appDir, "needle.txt"); - writeFileSync(sampleFile, "hello world\n"); + it.skipIf(!hasRipgrep)( + "POST /_decopilot_vm/grep and /_decopilot_vm/glob succeed (confirms uid/gid stripped from spawn)", + async () => { + // Create a file in appDir to search + const sampleFile = join(appDir, "needle.txt"); + writeFileSync(sampleFile, "hello world\n"); - const toBody = (obj: unknown) => - Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + const toBody = (obj: unknown) => + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); - const grepRes = await fetch( - `http://localhost:${daemonPort}/_decopilot_vm/grep`, - { - method: "POST", - headers: authHeaders({ "Content-Type": "application/json" }), - body: toBody({ pattern: "hello", output_mode: "content" }), - }, - ); - expect(grepRes.status).toBe(200); - const grepBody = (await grepRes.json()) as { results: string }; - expect(grepBody.results).toContain("hello world"); + const grepRes = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/grep`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ pattern: "hello", output_mode: "content" }), + }, + ); + expect(grepRes.status).toBe(200); + const grepBody = (await grepRes.json()) as { results: string }; + expect(grepBody.results).toContain("hello world"); - const globRes = await fetch( - `http://localhost:${daemonPort}/_decopilot_vm/glob`, - { - method: "POST", - headers: authHeaders({ "Content-Type": "application/json" }), - body: toBody({ pattern: "*.txt" }), - }, - ); - expect(globRes.status).toBe(200); - const globBody = (await globRes.json()) as { files: string[] }; - expect(globBody.files).toContain("needle.txt"); - }); + const globRes = await fetch( + `http://localhost:${daemonPort}/_decopilot_vm/glob`, + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: toBody({ pattern: "*.txt" }), + }, + ); + expect(globRes.status).toBe(200); + const globBody = (await globRes.json()) as { files: string[] }; + expect(globBody.files).toContain("needle.txt"); + }, + ); it("POST /_decopilot_vm/read returns file contents with line numbers", async () => { const sampleFile = join(appDir, "greet.txt"); @@ -470,22 +491,23 @@ describe("daemon e2e (reverse proxy)", () => { upstreamPort = upstreamServer.port as number; appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); daemonPort = freePort(); - const script = buildDaemonScript({ - upstreamPort: String(upstreamPort), - packageManager: null, - pathPrefix: "", - port: String(upstreamPort), - cloneUrl: "https://invalid.example.com/no-op.git", - repoName: "test/repo", - proxyPort: daemonPort, - bootstrapScript: "", - gitUserName: "test", - gitUserEmail: "t@e", - branch: "main", - daemonToken: DAEMON_TOKEN, - }) - .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") - .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + const script = patchForTest( + buildDaemonScript({ + upstreamPort: String(upstreamPort), + packageManager: null, + pathPrefix: "", + port: String(upstreamPort), + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }), + appDir, + ); const scriptPath = join(appDir, "daemon.js"); writeFileSync(scriptPath, script); daemonProc = spawn("bun", [scriptPath], { @@ -506,22 +528,23 @@ describe("daemon e2e (reverse proxy)", () => { upstreamPort = freePort(); appDir = mkdtempSync(join(tmpdir(), "daemon-e2e-")); daemonPort = freePort(); - const script = buildDaemonScript({ - upstreamPort: String(upstreamPort), - packageManager: null, - pathPrefix: "", - port: String(upstreamPort), - cloneUrl: "https://invalid.example.com/no-op.git", - repoName: "test/repo", - proxyPort: daemonPort, - bootstrapScript: "", - gitUserName: "test", - gitUserEmail: "t@e", - branch: "main", - daemonToken: DAEMON_TOKEN, - }) - .replaceAll(/,\s*uid:\s*DECO_UID\s*,\s*gid:\s*DECO_GID/g, "") - .replace(/const APP_ROOT = "\/app";/, `const APP_ROOT = "${appDir}";`); + const script = patchForTest( + buildDaemonScript({ + upstreamPort: String(upstreamPort), + packageManager: null, + pathPrefix: "", + port: String(upstreamPort), + cloneUrl: "https://invalid.example.com/no-op.git", + repoName: "test/repo", + proxyPort: daemonPort, + bootstrapScript: "", + gitUserName: "test", + gitUserEmail: "t@e", + branch: "main", + daemonToken: DAEMON_TOKEN, + }), + appDir, + ); const scriptPath = join(appDir, "daemon.js"); writeFileSync(scriptPath, script); daemonProc = spawn("bun", [scriptPath], { From f012ffd046897c8ec5c779554909a0790f6258f1 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 27 Apr 2026 10:07:19 -0300 Subject: [PATCH 4/6] refactor(sandbox): unified daemon across freestyle + docker + k8s (#3178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(prompts): add explicit verb-first titles to all studio guide prompts (#3145) * fix(prompts): derive display title from prompt name when title is absent Prompts registered via the old server.prompt() API don't carry a title field, causing the UI fallback (displayToolName) to display the raw namespaced slug — e.g. "H0jwredec58c… Self Writing Prompts" instead of "Writing Prompts". aggregatePrompts() now sets title to a human-readable Title Case string derived from the original (pre-namespace) prompt name when the upstream prompt has no title. Co-Authored-By: Claude Sonnet 4.6 * fix(ci): fix TS2532 in titleFromName and stabilize flaky jwt expiry test Use charAt(0) instead of [0] to avoid noUncheckedIndexedAccess error. Increase JWT expiry test from 1s/1.5s wait to 2s/3s to avoid false failures on loaded CI runners. Co-Authored-By: Claude Sonnet 4.6 * fix(prompts): add explicit verb-first titles to all guide prompts Switch from server.prompt() to server.registerPrompt() so the title field is included in the MCP response. Each guide prompt now has a clear verb-first title (e.g. "Create Agents", "Update Connections") rather than the garbled fallback derived from the kebab-case name. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 * [release]: bump to 2.274.1 * fix(deco-sites): avoid duplicate profile insert on service account creation (#3176) Supabase has a DB trigger that auto-creates a profiles row when a new auth user is created. The explicit INSERT was hitting a unique constraint violation (profiles_user_id_key) on the first call, causing a 409/500. Now we check if the profile already exists before attempting to insert. Co-authored-by: Claude Opus 4.6 * [release]: bump to 2.274.2 * feat(analytics): integrate PostHog for server-side and client-side tracking (#3162) * feat(analytics): integrate PostHog for server-side and client-side event tracking Adds PostHog Node.js SDK (server) and posthog-js (client) with a no-op fallback when POSTHOG_KEY is unset, so self-hosted deployments are unaffected. Instruments key lifecycle events: org creation/join, user auth, connection/API key/automation CRUD, thread creation, topup URL, and AI streaming sessions. Co-Authored-By: Claude Sonnet 4.6 * feat(analytics): expand PostHog event coverage and fix gaps Structured event taxonomy for chat, tools, credits, and settings. Chat hierarchy (renamed for consistency): - chat_started, chat_opened, chat_message_sent/started/completed/failed/ stopped/aborted — per-thread and per-completion granularity - chat_archived, chat_unarchived, chat_deleted — thread lifecycle - chat_picker_opened/closed/item_selected — @/slash picker with abandonment detection (outcome + duration) - chat_model_changed, chat_credential_changed - chat_voice_started (with outcome: started | unsupported | permission_denied) Tool calls: - tool_called fires for both MCP passthrough and built-in tools with tool_source discriminator, annotations (readOnly/destructive/ idempotent/openWorld), latency, and error status Credits & revenue: - credits_topup_clicked (intent), credits_topup_requested (server), credits_topped_up_detected (heuristic via balance delta), credits_exhausted_shown, credits_empty_state_shown/dismissed Organization/team: - organization_created now also fires from Better Auth default-org auto-creation hook (was only domain-setup); closes undercounting gap - organization_member_role_updated, organization_member_removed - ai_provider_key_created, ai_provider_key_deleted - chat_message_aborted for server-side abort visibility Navigation & UI: - nav_item_clicked, settings_nav_clicked, agent_toolbar_toggled - sidebar_agent_pin_clicked, agent_browser_opened, agent_create_new_clicked, agent_import_clicked, agent_template_clicked - mcp_app_opened (real MCP app renderer), vm_preview_loaded Privacy & session replay: - Session recording enabled at PostHog project level (10% sample, 10s min duration) - ph-no-capture class applied to AI provider API keys and connection secrets so they are fully blocked from replays - Frontend exception capture enabled ($exception events) Team analytics: - \$groupidentify fires on organization creation - All server events include groups: { organization: org_id } for team-level filtering and breakdowns Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track home page events — tiles, tools popover, connections dialog, recruit modals Wires structured PostHog events for the home-page surface identified in the page-by-page audit: - Home agent tiles (template/existing/recent), Create agent, See all - Chat mode toggles from tools popover + pill-dismiss (plan/gen-image/web-search) - Image/search model selection from tools popover - Prompt insertion from tools popover - Connect-tools banner + dialog-opened (with source across all callers) - Connection add flows (use_existing / clone / connect_new) + OAuth boundaries - Recruit-modal confirmed/failed (site-diagnostics / ai-image / ai-research) - Deco.cx site import started/succeeded/failed * feat(analytics): track agent instructions/connect page events Adds structured PostHog events for the agent detail page (instructions / connections / layout) and the Connect share modal: - agent_subtab_changed — instructions/connections/layout switches - agent_instructions_template_inserted, agent_instructions_improve_clicked - agent_updated — on successful form save, lists dirty field roots and instructions length when dirty - agent_test_clicked, agent_delete_requested, agent_deleted - agent_connect_modal_opened + agent_connect_action (copy_url / install_cursor / install_claude_code / typegen_copy_command / typegen_copy_env) - agent_typegen_key_generated / _failed - agent_connection_removed, agent_connection_settings_opened, agent_connection_instance_switched, agent_connection_new_instance_requested - connection_oauth_succeeded / _failed on agent reauthenticate flow - main_panel_tab_clicked — top Instructions/Connections/Automations/ Layout/pinned-view tabs (with tab_kind + was_active) * feat(analytics): track tasks panel + chat message actions Tasks panel (left column): - tasks_panel_member_filter_changed — all/mine toggle - tasks_panel_filter_changed — all/manual/automation toggle - tasks_panel_new_clicked — pencil icon to create a new task - tasks_panel_task_clicked — row select (dedupes no-op re-clicks) - tasks_panel_task_archived — frontend intent (server-side chat_archived still fires through COLLECTION_THREADS_UPDATE) Chat message actions: - chat_message_copied — assistant message copy-to-clipboard, includes message_id + char count Chat input + model selector events on this surface were already wired in the home-page pass; nothing new to add there. * feat(analytics): track settings pages — general, connections, agents, automations, store, brand, AI providers, monitor, members/roles, SSO, profile Wires PostHog events for every settings screen: General: - organization_settings_updated (dirty fields) - organization_domain_claimed / _cleared - organization_auto_join_toggled Connections list: - connections_page_tab_changed, connections_custom_dialog_opened, connection_custom_created, connection_add_clicked (source=connections_page), connections_community_warning_confirmed, connection_oauth_succeeded/_failed (flow=connections_page_connect), connections_bulk_delete / _status_toggled / _add_to_agent Agents list: - agents_list_template_clicked, agent_create_clicked (source=agents_list/agents_list_empty, method), agent_deleted (source=agents_list) Automations: - automations_list_row_clicked, automations_empty_state_browse_agents_clicked - automation_improve_clicked, automation_updated, automation_test_clicked, automation_trigger_added (cron / event), automation_new_clicked Store: - store_private_registry_added / _removed - store_registry_toggled Brand Context: - brand_created, brand_extract_started / _succeeded - brand_updated, brand_archived / _restored, brand_set_as_default AI Providers: - ai_provider_connect_clicked (method) - ai_provider_oauth_succeeded / _failed - ai_provider_cli_activated / _activate_failed - ai_provider_provision_succeeded / _failed Monitor: - monitoring_tab_changed, monitoring_time_range_changed, monitoring_live_toggled Members: - member_invited, member_removed, member_role_updated, invitation_role_updated - role_created, role_updated, role_deleted, role_members_updated SSO: - sso_configured / _config_updated / _config_removed - sso_enforcement_toggled Profile & Preferences: - profile_updated - preferences_theme_changed, preferences_notifications_toggled / _permission_denied, preferences_sounds_toggled / _previewed, preferences_tool_approval_changed, preferences_experimental_vibecode_toggled * feat(analytics): patch recruit modal + oauth timeout + extract-failed gaps - agent_recruit_confirmed / _failed now also fire from lean-canvas-recruit-modal.tsx and studio-pack-recruit-modal.tsx - ai_provider_oauth_failed fires on the 2-minute OAuth timeout path (was previously silent) - brand_extract_failed fires on BRAND_CONTEXT_EXTRACT error - agent_deleted from virtual-mcp/index.tsx now passes source: "agent_detail" for consistency with agents_list * refactor(analytics): drop credits_topup_requested + session-based agent/automation_updated Removals: - credits_topup_requested: removed from AI_PROVIDER_TOPUP_URL tool handler. It was a near-duplicate of the frontend credits_topup_clicked in the standard UI flow, and neither is an authoritative payment event. Keep credits_topup_clicked as the intent signal. Session-based tracking for agent_updated and automation_updated: - Auto-saves still persist every ~1s (product behavior unchanged). - PostHog now emits one event per edit SESSION, not per save. - A session ends after 30s of quiet OR an explicit flush (sub-tab change / test / improve / delete). - New props on both events: save_count — how many auto-saves occurred during the session edit_duration_ms — Date.now() delta from first save in session 'fields' is now the union of all dirty fields during the session. - Cuts event volume ~10-15x for a typical instructions edit. * docs(analytics): PostHog events catalog, review, and dashboards proposal Temporary reference docs for the PostHog instrumentation review. Three files at repo root so they're easy to share and easy to delete later: - posthog-events-catalog.md — every tracked event with exact trigger + props + misleading- interpretation guards - posthog-events-review.md — T1/T2/T3 triage, trigger-correctness pass, fixed/open gaps - posthog-events-dashboards.md — 14 dashboard proposals + 17 correlation questions + "Do-NOT labels" guardrails These are NOT the Astro docs site — delete them once the dashboards are built and the catalog lives in a better home. * feat(analytics): track signed_out event from both sign-out call sites Fires before authClient.signOut() so the event still carries the user's distinct_id; PostHog reset() then clears identity for the next session. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track chat_tools_popover_opened on Tools button click Discovery signal — the inner items already track their own actions (chat_mode_changed, chat_prompt_inserted, chat_image_model_selected, chat_search_model_selected) but opening the popover itself was untracked, so we couldn't measure the open→action funnel. Fires only on the open transition, not on close. Carries chat_mode for segmenting by current mode. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): add app_name to connection_created/deleted events Lets you break down connection adoption and churn by provider (Linear, Slack, HubSpot, etc.) directly in PostHog without joining against the connections table. Nullable — STDIO/HTTP connections without a registry app will report null. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track agent_connection_attached at 5 attach points Authoritative agent-scoped attach signal — fires whenever a connection becomes attached to an agent regardless of whether the connection was brand-new, cloned, or reused. Closes the gap where the existing connection_created (server) only fired for new rows. Modes: existing | clone | new | custom. Carries agent_id, connection_id, app_name (nullable). Threaded via a new agentId prop on AddConnectionDialog (add mode only — browse mode keeps it optional). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(analytics): split ai_provider_oauth_timeout from oauth_failed; suppress race Two bugs at the same site: 1. Race: if the popup posts back and exchangeOAuth (the async token swap) takes longer than the remaining 2-min timeout window, the timeout would fire ai_provider_oauth_failed{error:"timeout"} alongside the eventual ai_provider_oauth_succeeded. User saw an error toast and a failed event even though the connection worked. 2. Semantics: a 2-min "user never came back from popup" timeout is user abandonment, not an OAuth-protocol failure. Mixing both into oauth_failed inflates the failure rate and obscures real exchange failures. Fix: - Local exchangeStarted flag in the effect — set when the popup posts back, checked by the timeout. Once exchange begins, its own onError handler is the authoritative failure signal. - New event ai_provider_oauth_timeout for the popup-abandonment case. - ai_provider_oauth_failed now only fires for actual exchange failures. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): report React error boundary catches to PostHog PostHog's capture_exceptions: true only sees what bubbles to window.onerror / unhandledrejection. React error boundaries catch render- and commit-phase errors BEFORE they reach the window, so anything that hits a boundary (the "removeChild" class, render crashes, etc.) was previously invisible to PostHog. - Add captureException wrapper to posthog-client (try/catch so an analytics failure never blocks the fallback UI). - Wire both ErrorBoundary and ChunkErrorBoundary componentDidCatch to call it with route + componentStack + boundary tag. The boundary prop ("default" / "chunk_root") lets you split React-boundary catches from autocapture in PostHog dashboards. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(analytics): remove planning docs from branch Moved to local Downloads folder; these were working notes (events catalog, dashboards proposal, review) that don't belong in the shipped PR. The event changes themselves are in the preceding commits; nothing in the code or dashboards references these files. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track member_invite_failed on invite mutation error The success path fired member_invited; the error path only showed a toast, so invite failures were invisible in PostHog. Now captures count, role, and error message on failure. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track failure counterparts for silent onError paths Several mutations fired success events but swallowed errors with a toast-only onError, making failures invisible in PostHog. Added matching _failed events mirroring the success event's props + an error field. Covers 8 gaps: - member_remove_failed - member_role_update_failed - invitation_role_update_failed - role_create_failed / role_update_failed / role_members_update_failed - role_delete_failed - organization_settings_update_failed - organization_domain_claim_failed - organization_domain_clear_failed - organization_auto_join_toggle_failed (Already-good paths like deco_site_import_failed, ai_provider_*_failed, brand_extract_failed, agent_recruit_failed are unchanged.) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track user_signup_failed + user_signin_failed The auth form's emailPasswordMutation had no tracking at all — neither success nor failure. The server-side user_signed_up fires only AFTER a DB row is created, so pre-insert failures (network, validation, email-already-exists, weak password) were completely invisible in PostHog. Since the same mutation handles both signup and signin, the onError branches on isSignUp to fire the right event: - user_signup_failed - user_signin_failed Success path intentionally left untracked: the authoritative signal is the server-side user_signed_up (signup) or presence of the session cookie on subsequent requests (signin). No client-side duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track password-reset and email-OTP auth flows Fills the tracking gap on the remaining 3 auth mutations in unified-auth-form.tsx. New events: - password_reset_requested + password_reset_request_failed - email_otp_sent + email_otp_send_failed - email_otp_verify_failed Success for sendOtp / password-reset is tracked because those are intermediate states (user stays on the form waiting for email). Success for verifyOtp is NOT tracked — it redirects on success, matching the signin pattern where the session cookie is authoritative. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(analytics): remove unused setOrganizationGroup export Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 * [release]: bump to 2.275.0 * fix(simple-model-mode): gate on provider availability and fix selector bugs (#3177) * fix(simple-model-mode): gate on provider availability and fix selector bugs - Disable toggle when no AI provider is connected; clear stale draft when providers are removed so reconnecting a different provider doesn't carry over unavailable model selections - Auto-fill defaults reactively when models finish loading, clearing slots whose keyId no longer exists - Resolve correct provider logo via the key's actual providerId (was hardcoded to "deco") - Add claude-code to FAST_MODEL_PREFERENCES so Haiku is picked as default - Hide Image/Web research selector with "Not available" note when the current provider has no matching models - Fix modal credential switcher reverting selection — slot sync now runs only when slot.keyId actually transitions, not on every render Co-Authored-By: Claude Opus 4.7 (1M context) * style(simple-model-mode): clean up settings panel layout Remove row dividers, hide Save button when no provider is connected, move spacing so the toggle row has no padding when collapsed, and separate Chat/Other model sections with a single divider instead of per-row borders. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(simple-model-mode): address review feedback - Fix dead guard in model-sync effect: compare chat tiers field-by-field instead of identity-comparing a freshly built object against state, which was always false and caused setDraft on every models/keys change. - Avoid flashing "Not available with current provider" on filtered rows while useAiProviderModels is still loading by gating on isLoading. Co-Authored-By: Claude Opus 4.7 * refactor(org-settings): extract SimpleModeConfig zod schemas into shared schema module Co-Authored-By: Claude Opus 4.7 (1M context) * feat(org-settings): expose and accept simple_mode in ORGANIZATION_SETTINGS_GET/UPDATE Adds simple_mode to the generic org-settings tool schemas (input for UPDATE, output for both) so callers can read/write this field through the same pair of tools as sidebar_items, enabled_plugins, and registry_config. New tests cover round-trip behavior and verify that partial updates do not clobber unrelated fields. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(org-settings): add unified useOrganizationSettings hook with slice wrappers Introduces a single query + mutation hook targeting organization_settings, plus thin named wrappers (useSimpleMode, useUpdateSimpleMode, useRegistryConfig, useUpdateRegistryConfig, useEnabledPlugins) that share one query key and a setQueryData-based write path. Existing callers will migrate in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(web): route simple-mode consumers through useOrganizationSettings Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(web): route registry-config consumers through useOrganizationSettings Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(web): route plugins form and shell layout through useOrganizationSettings Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(web): delete use-ai-simple-mode and use-registry-settings hooks Migrates the remaining three registry consumers (use-install-from-registry, use-enabled-registries, use-registry-connections) to the unified useOrganizationSettings hook and its useIsRegistryEnabled / useRegistryConfig wrappers, then deletes the two legacy hook files. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(query-keys): remove aiSimpleMode and registryConfig keys Both slices now share KEYS.organizationSettings; the dedicated keys are no longer referenced anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(simple-mode)!: delete AI_SIMPLE_MODE_GET/UPDATE tools Consolidated into ORGANIZATION_SETTINGS_GET/UPDATE in an earlier commit. Drops the dedicated tool files, their exports from the ai-providers registry, their CORE_TOOLS registration, and their entries in the registry-metadata name/description/category maps. BREAKING CHANGE: external callers of AI_SIMPLE_MODE_GET / AI_SIMPLE_MODE_UPDATE must switch to ORGANIZATION_SETTINGS_GET / ORGANIZATION_SETTINGS_UPDATE with the simple_mode field. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(org-settings): drop unused exports flagged by knip Unexports the internal-only ModelSlotSchema and useOrganizationSettings, and deletes useEnabledPlugins (shell-layout uses the suspense variant instead). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(simple-mode): migrate SimpleModeSection to react-hook-form Drops the useState + synced-boolean + JSON.stringify-isDirty state machine in favor of useForm({ values: simpleMode, resolver, mode: onChange }). Each model row is now wrapped in a react-hook-form Controller. The explicit Save button and behavior are preserved — autosave lands in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(simple-mode): autosave form changes with stateless status indicator Replaces the explicit Save button with a 250ms-debounced autosave effect watching form state. The debounce coalesces multi-field writes (toggle-on defaults, stale-key clearing) into a single mutation. A dumb AutosaveStatus component next to the card title shows "Saving…" or "Saved" as a pure derivation of mutation + form state — no local booleans, no timers. On mutation error the form reverts to the last-known-good server value and a toast surfaces the error. The success toast is removed to avoid spamming on every dropdown change. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chat-context): store ModelRef instead of full AiProviderModel Collapses five localStorage keys to four and drops hundreds of bytes of cached metadata per slot (title/description/logo/capabilities/limits/costs). Makes Simple Mode and regular chat-model resolution mutually exclusive: when Simple Mode is enabled the stored pick is not consulted, eliminating the silent-shadowing fallback chain the UI had no way to communicate to users. credentialId becomes session-only state — its only role is letting the picker browse a credential before the user commits. On commit (setModel) the session override clears and the model's keyId becomes the source of truth. All stored refs now flow through a single findModel validator that clears stale values from localStorage when they reference deleted keys or models. The main chat model used to skip this validation while image and deep-research did it; the asymmetry is gone. chatSimpleModeTier validation resolves the orphan case: a stored tier that is not configured on the server silently falls through to the first configured tier, eliminating stale reactivation when Simple Mode is re-enabled later. LOCALSTORAGE_KEYS.chatSelectedKeyId is removed — it was a pure duplicate of chatSelectedModel.keyId. Existing values in users' localStorage are harmless (~30 bytes, unreferenced from now on). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-context): don't write localStorage during render The initial ref-is-stale cleanup in the validation pass called setStoredChatRef(null) synchronously during render, which is a state-set-during-render anti-pattern the project avoids. Drop the on-read cleanup. Stale refs stay on disk harmlessly: validation returns null, resolution falls through to the default, and the next setModel call overwrites the ref cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-context): Simple Mode slots synthesize model when key exists findModel was rejecting Simple Mode slots whose model didn't appear in the current credential's model list — which is the common case, since allKeyModels is only fetched for effectiveKeyId, and Simple Mode slots typically reference a different credential. That made selectedModel null and disabled the send button. Restore the old behavior of synthesizing a minimal AiProviderModel from the slot's { keyId, modelId, title } when the key still exists. Still enforce the key-existence check introduced in the refactor — admin-deleted providers still produce null. Pass the slot's title through to the synthesized object so the picker label reflects the configured tier. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-context): fetch models per Simple Mode slot for real capabilities Previously, Simple Mode slots pointing at a different credential than effectiveKeyId resolved via synthesize-from-ref with capabilities: []. That broke UI gates like file upload: the picker thought Sonnet (as a Simple Mode Smart slot) had no file capability, so the attachment UI was disabled. Fetch models per slot keyId via useAiProviderModels — React Query's per- query cache keeps the additional fetches cheap, and each hook short-circuits when the slot is unset (enabled: false). findModel now receives the slot's own key's models list and returns the real AiProviderModel with full capabilities; the synthesize fallback only triggers if the slot's key still exists but that key's model list hasn't loaded yet. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-context): match findModel by modelId only, attach keyId to hit The AiProviderModel objects returned by AI_PROVIDERS_LIST_MODELS don't carry a keyId field — it's a client-side-only marker injected downstream (see selectDefaultModel's withKey helper). My findModel was requiring m.keyId === ref.keyId, which always failed against real API responses, pushing every lookup into the synthesize fallback with capabilities: []. That's why Simple Mode's Sonnet slot resolved with no "vision" capability and the file-upload UI stayed locked out. Match by modelId only within the provided model list (list is already scoped to one credential), then spread the hit with ref.keyId attached. Synthesize only fires when the model truly isn't in the list — list still loading or the user manually corrupted localStorage. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: gimenes * [release]: bump to 2.275.1 * feat(sandbox): unified sandbox daemon Port the in-VM daemon from JS-string scripts to a TypeScript package (@decocms/sandbox) running under Bun.serve with web-standard Request/Response. Drop the mesh-side proxy at /api/sandbox/:handle/_decopilot_vm/* in favor of direct previewUrl access from the web client. Bundle the daemon into the Docker image, remove bearer-token auth on _decopilot_vm/*, and route all traffic through the daemon port. - Rename packages/mesh-plugin-user-sandbox → packages/sandbox - Port daemon modules: config, paths, auth, events (sse/replay/broadcast), process (run-process, dev-autostart, script-discovery), routes (bash, fs, exec, kill, scripts, body-parser, events-stream, health), setup (clone, identity, branch, install, resume, orchestrator), git (branch-status, git-sync), probe, proxy, entry - DaemonHealth contract with bootId; persist daemonBootId from /health - Switch UI + vm-tools to /_decopilot_vm/* with base64 bodies - Direct previewUrl wiring for VmEventsProvider and env.tsx exec/kill - Auto-start owns dev lifecycle; drop explicit /dev/start, /dev/stop - CI: bun-build step, docker smoke job, ripgrep install for e2e - Drop translateDaemonPath, daemon-script.ts, dev-server.ts; tests relocate to packages/sandbox/daemon/ Co-Authored-By: Claude Opus 4.7 (1M context) * feat(git-panel): GitHub PR management panel polish Iterate on the GitTab UI for github-linked virtualmcps: - PrOverview header: title with inline PR # link, author, base ← head - PrSubTabs: text-style tab bar (Description / Changes {n} / Checks) with sliding underline indicator that animates between triggers; drop h-[52px] and border-b chrome - DescriptionTab: drop duplicate h1 and bordered body card - ChangesTab and ChecksTab: drop outer padding (owned by page container) - Real usePrByBranch state machine drives State B / C / D — no mocks Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sandbox): add computeHandle for slugified URLs Co-Authored-By: Claude Sonnet 4.6 * test(sandbox): cover whitespace-only branch in computeHandle Co-Authored-By: Claude Sonnet 4.6 * feat(sandbox/freestyle): use slugified handle for preview domain Co-Authored-By: Claude Sonnet 4.6 * feat(sandbox/docker): use slugified handle as container name * test+fix(sandbox/docker): cover adopt path and recover from --name collision Two follow-ups for the slugified-sandbox handle work: 1. Add a test for the adopt-by-label path. `findExisting` now returns a container *name* (not an ID) since it queries with `--format {{.Names}}`. The new test asserts that a labeled, already-running container is adopted via name and that `docker run` is not called again. 2. Defensively recover from `--name` collisions in `provision()`. `findExisting` only adopts *running* containers, so a stopped same-name orphan left behind by a crash that bypassed `--rm` cleanup will collide on the explicit `--name`. Detect the "is already in use" error, force-remove the orphan, and retry `startContainer` once. Covered by a new test. * chore: checkpoint in-flight chat/sandbox work before task-creation refactor Bundled commit of in-progress changes across: - chat URL-state cleanup (chat-context, use-chat-navigation, side-panel-chat, use-task-manager) — preparing thread.branch as the single source of truth - agent-shell layout + main-panel tabs reshuffle - vm preview/env panel polish - sandbox docker runner / local-ingress refinements - packages/sandbox README updates Committed as a single checkpoint to keep the upcoming task-creation unification refactor (per docs/superpowers/plans/2026-04-25-task-creation-unification.md) on a clean base. * refactor(thread): move branch-name generator next to create tool Co-Authored-By: Claude Sonnet 4.6 * feat(thread/schema): require virtual_mcp_id, drop branch from create input * test(thread): add buildThreadTestContext helper Co-Authored-By: Claude Sonnet 4.6 * feat(thread/create): server-derive branch from vMCP github metadata Look up the vMCP on create, derive branch from githubRepo metadata server-side, and set idempotentHint=true on the tool annotations. Co-Authored-By: Claude Sonnet 4.6 * feat(storage/threads): idempotent create — ON CONFLICT DO NOTHING returning existing Co-Authored-By: Claude Sonnet 4.6 * feat(thread/update): reject branch=null for github-linked threads Adds an invariant check in COLLECTION_THREADS_UPDATE: if the thread's vMCP has metadata.githubRepo, setting branch=null is rejected with an error. Switching to a different non-null branch remains allowed. Co-Authored-By: Claude Sonnet 4.6 * refactor(decopilot/memory): require thread to exist; drop create-on-missing fallback - Remove fallback create path from createMemory; it now throws if thread_id is missing or thread not found - Drop triggerId, virtualMcpId, branch from MemoryConfig (thread row already carries that data) - Remove unused generatePrefixedId import from memory.ts - Add guard in stream-core.ts to surface missing taskId early - Add memory.test.ts covering success and not-found cases Co-Authored-By: Claude Sonnet 4.6 * feat(web/hooks): add useTask, useTasks, useTaskActions Thin collection-pattern wrappers backed by COLLECTION_THREADS_* tools, mirroring useConnection/useConnections/useConnectionActions. Task type adapts ThreadEntity to satisfy CollectionEntity (updated_by null→undefined). Co-Authored-By: Claude Sonnet 4.6 * feat(web/hooks): add useEnsureTask for create-on-404 routing Co-Authored-By: Claude Sonnet 4.6 * feat(web/routes): create-on-404 task-route boundary Wire /$org/$taskId to a real component that calls useEnsureTask and renders a "Creating task…" boundary while the mutation is in flight, delegating to the surrounding layout once the task is ready. Co-Authored-By: Claude Sonnet 4.6 * refactor(chat-context): navigate-only createTask; drop vmcp override Co-Authored-By: Claude Sonnet 4.6 * refactor(chat-nav): collapse virtualMcpOverride into virtualmcpid Remove the `virtualMcpOverride` URL search param and `setVirtualMcpOverride`/`setVirtualMcpId` ephemeral override mechanism. Navigation now uses a single `virtualmcpid` param; automation-detail passes the target agent via `createTaskWithMessage({ virtualMcpId })` instead of calling the now-deleted prefs setter. Co-Authored-By: Claude Sonnet 4.6 * refactor(chat/task): drop optimistic createTask, buildOptimisticTask, addTaskToCache Co-Authored-By: Claude Sonnet 4.6 * refactor(web): drop seedNewTask and useCreateTaskAndNavigate; navigate-only Route loader now handles task creation; navigating to a fresh id is sufficient. Co-Authored-By: Claude Sonnet 4.6 * refactor(branch-picker): drop client-side new-branch generator Remove the "New" button and generateBranchName import from the branch picker. Users wanting a fresh branch should click "+ New Task" instead, which triggers server-side branch generation. Co-Authored-By: Claude Sonnet 4.6 * chore(branch-name): drop shim — server-only generation * fix(agent-shell-layout): hoist useEnsureTask into the layout The /$org/$taskId route loader's TaskRoute component never mounted because agent-shell-layout doesn't render — it composes the chat UI directly. Result: useEnsureTask never ran, threads were never created server-side, and the empty-state branch picker showed "Select branch…" instead of a real branch. Move the create-on-404 gate into AgentInsetProvider (which actually renders) and short-circuit to a "Creating task…" boundary while the mutation is in flight. Drop the now-dead routes/orgs/task-route.tsx. Verified: visiting //?virtualmcpid= creates the thread server-side with branch=deco/- and the empty state shows that branch. * fix(use-ensure-task): invalidate legacy task list cache after create The empty-state branch picker reads from chat-context's tasks.find(t => t.id === effectiveTaskId).branch, where tasks comes from useTaskManager's useTasks hook (legacy KEYS.tasksPrefix query). useCollectionActions only invalidates queries shaped [client, scopeKey, "", "collection", ...] — KEYS.tasks doesn't match that predicate, so the list stays stale and the picker shows "Select branch…" until an unrelated SSE event happens to refetch. After a successful create, also invalidate KEYS.tasksPrefix(locator) so the picker reflects the server-generated branch immediately. Verified: clicking "New tasks" from an existing /\$org/\$taskId navigates to a fresh id, the route's create-on-404 fires, and the empty state shows the server-generated branch (e.g. deco/true-fern) without waiting on SSE. * fix(use-ensure-task): per-id ref guard so back-to-back creates work AgentInsetProvider does not unmount across task navigations, so the hook (and the useTaskActions mutation it owns) persists. The boolean createStartedRef and the actions.create.status === \"idle\" guard both stuck after the first successful create — every subsequent \"+ New task\" click sat in the \"Creating task…\" boundary forever because the gate refused to re-fire for the new id. Track the id we last fired for instead. Refs mutate synchronously so the gate stays Strict-Mode safe. Verified: three consecutive \"New tasks\" clicks each produce a fresh server-generated branch (deco/thin-stone → deco/hollow-flint → …) and the empty-state branch picker reflects each one immediately. * refactor(use-ensure-task): drop refs in favor of useEffect The previous version leaned on a useRef gate to dedupe the create mutation across renders, which the React Compiler can't reason about the way it can about effects. Refactor: - Replace the render-time gate + ref with a single useEffect whose dependency array re-runs on (id, query.isSuccess, query.data, ensureCreate). - Own the create mutation locally via useMutation instead of routing through useTaskActions(). This drops the user-facing "Item created successfully" toast for ensure-create (the user did not initiate it) and lets the hook control its own onSuccess invalidation: the canonical collection cache, the legacy KEYS.tasksPrefix list, and the local ensure query refetch. - React 19 Strict Mode dev double-mount stays silent because the server's INSERT … ON CONFLICT DO NOTHING handles duplicate requests with no row collision and the private mutation has no toast. - Remove the isNotFoundError helper (the COLLECTION_THREADS_GET tool returns { item: null } on missing, never throws "not found"). Verified live with two back-to-back "+ New task" clicks: each spawns a fresh server-generated branch (deco/lunar-anchor → deco/olive-sage) and the empty-state branch picker reflects each one immediately. * feat(threads): branch carry-over for "+ New Task" buttons When the user creates a new task via the chat header, tasks-panel "+ New tasks" button, or sidebar agent click on the same vMCP, the new thread inherits the active task's branch instead of getting a fresh server-generated one. Lets the new thread land on the same warm sandbox subdomain. Server-side resolution in COLLECTION_THREADS_CREATE: 1. data.branch when provided → use it. 2. Otherwise pick the most-recently-touched branch from the user's vmMap[userId] (sorted by createdAt desc). 3. Fall back to generateBranchName() when vmMap has no entries. Threads created on a vMCP without a githubRepo always get null. Schema: re-add `branch` to ThreadCreateDataSchema as optional. Client wiring (all four "+ New" entry points): - chat-context.createTask / createTaskWithMessage await taskActions.create with currentBranch (only carried for same-vMCP createTaskWithMessage). - shell-layout.usePanelActions.createNewTask reads the active task's branch from the React Query cache and passes it through. - use-layout-state.useChatMainPanelState.createNewTask same. - sidebar agents-section uses a new useNavigateToNewTaskWithBranchCarry hook that only carries when the clicked vMCP matches the URL's current virtualmcpid. useTaskActions().create.mutateAsync now also invalidates the legacy KEYS.tasksPrefix cache so the chat-context branch picker picks up the new thread immediately. Without this the picker stayed stale (useCollectionActions only invalidates collection-shaped keys). Tests: three new server-side cases — input branch honored, input branch ignored without githubRepo, vmMap most-recent branch picked when no input + github vMCP. Verified live: from a task on deco/golden-falcon, clicking "+ New tasks" in the tasks panel produces a new task that also lands on deco/golden-falcon and reuses the same golden-falcon-19ce8.localhost preview. --------- Co-authored-by: rafavalls Co-authored-by: Claude Sonnet 4.6 Co-authored-by: github-actions[bot] Co-authored-by: guitavano --- .github/workflows/test.yml | 51 + apps/mesh/package.json | 2 +- apps/mesh/spec/monitoring-share-plugin.md | 2 +- apps/mesh/src/api/app.ts | 7 +- .../routes/decopilot/built-in-tools/index.ts | 2 +- .../built-in-tools/vm-tools/index.ts | 25 +- .../built-in-tools/vm-tools/types.ts | 2 +- .../src/api/routes/decopilot/memory.test.ts | 45 + apps/mesh/src/api/routes/decopilot/memory.ts | 66 +- .../api/routes/decopilot/run-registry.test.ts | 10 +- .../src/api/routes/decopilot/stream-core.ts | 7 +- .../src/api/routes/sandbox-daemon.test.ts | 185 --- apps/mesh/src/api/routes/sandbox-daemon.ts | 85 -- apps/mesh/src/cli/sandbox-image.ts | 2 +- apps/mesh/src/index.ts | 6 +- apps/mesh/src/sandbox/lifecycle.test.ts | 2 +- apps/mesh/src/sandbox/lifecycle.ts | 4 +- .../src/storage/sandbox-runner-state.test.ts | 2 +- apps/mesh/src/storage/sandbox-runner-state.ts | 4 +- apps/mesh/src/storage/threads.ts | 19 +- apps/mesh/src/storage/types.ts | 2 +- .../thread}/branch-name.test.ts | 0 .../{shared => tools/thread}/branch-name.ts | 0 apps/mesh/src/tools/thread/create.test.ts | 184 +++ apps/mesh/src/tools/thread/create.ts | 82 +- apps/mesh/src/tools/thread/helpers.test.ts | 2 +- apps/mesh/src/tools/thread/schema.ts | 14 +- apps/mesh/src/tools/thread/test-helpers.ts | 144 +++ apps/mesh/src/tools/thread/update.test.ts | 98 ++ apps/mesh/src/tools/thread/update.ts | 21 + apps/mesh/src/tools/vm/start.test.ts | 4 +- apps/mesh/src/tools/vm/start.ts | 4 +- apps/mesh/src/tools/vm/stop.test.ts | 4 +- apps/mesh/src/tools/vm/stop.ts | 2 +- .../src/web/components/chat/chat-context.tsx | 88 +- .../chat/hooks/use-chat-navigation.ts | 91 +- .../web/components/chat/side-panel-chat.tsx | 9 +- .../components/chat/task/cache-operations.ts | 43 +- .../src/web/components/chat/task/helpers.ts | 20 - .../components/chat/task/use-task-manager.ts | 46 +- .../web/components/sidebar/agents-section.tsx | 55 +- .../thread/github/branch-picker.tsx | 13 +- .../components/thread/github/changes-tab.tsx | 14 +- .../components/thread/github/checks-tab.tsx | 12 +- .../thread/github/description-tab.tsx | 5 +- .../web/components/thread/github/git-tab.tsx | 53 +- .../thread/github/header-actions.tsx | 4 +- .../components/thread/github/pr-sub-tabs.tsx | 82 +- apps/mesh/src/web/components/vm/env/env.tsx | 36 +- .../components/vm/hooks/vm-events-context.tsx | 6 +- .../src/web/components/vm/preview/preview.tsx | 10 +- .../web/hooks/use-create-task-and-navigate.ts | 26 - apps/mesh/src/web/hooks/use-ensure-task.ts | 122 ++ apps/mesh/src/web/hooks/use-layout-state.ts | 22 +- apps/mesh/src/web/hooks/use-tasks.ts | 86 ++ apps/mesh/src/web/index.tsx | 1 - .../web/layouts/agent-shell-layout/index.tsx | 222 ++-- .../main-panel-tabs/use-main-panel-tabs.ts | 5 +- apps/mesh/src/web/layouts/shell-layout.tsx | 42 +- apps/mesh/src/web/lib/query-keys.ts | 4 + .../src/web/lib/read-cached-task-branch.ts | 29 + .../views/automations/automation-detail.tsx | 3 +- bun.lock | 122 +- knip.jsonc | 2 +- .../mesh-plugin-user-sandbox/image/Dockerfile | 55 - .../mesh-plugin-user-sandbox/image/daemon.mjs | 308 ----- .../image/daemon/auth.mjs | 16 - .../image/daemon/config.mjs | 31 - .../image/daemon/deco-watcher.mjs | 65 - .../image/daemon/dev-process.mjs | 286 ----- .../image/daemon/dev-state.mjs | 36 - .../image/daemon/events.mjs | 131 -- .../image/daemon/exec-process.mjs | 100 -- .../image/daemon/exec-state.mjs | 12 - .../image/daemon/fs-ops.mjs | 246 ---- .../image/daemon/http-helpers.mjs | 30 - .../image/daemon/workdir.mjs | 102 -- .../server/runner/freestyle/daemon-script.ts | 1102 ----------------- .../server/runner/freestyle/index.ts | 2 - .../server/runner/freestyle/runner.test.ts | 67 - .../server/runner/shared/dev-server.ts | 57 - .../server/runner/shared/handle.ts | 10 - packages/sandbox/.gitignore | 1 + .../README.md | 16 +- packages/sandbox/daemon/config.test.ts | 93 ++ packages/sandbox/daemon/config.ts | 87 ++ packages/sandbox/daemon/constants.ts | 29 + .../daemon/daemon.e2e.test.ts} | 212 ++-- packages/sandbox/daemon/entry.ts | 153 +++ .../sandbox/daemon/events/broadcast.test.ts | 36 + packages/sandbox/daemon/events/broadcast.ts | 47 + packages/sandbox/daemon/events/replay.test.ts | 25 + packages/sandbox/daemon/events/replay.ts | 23 + packages/sandbox/daemon/events/sse-format.ts | 6 + packages/sandbox/daemon/events/sse.test.ts | 34 + packages/sandbox/daemon/events/sse.ts | 93 ++ packages/sandbox/daemon/git/branch-status.ts | 108 ++ packages/sandbox/daemon/git/git-sync.test.ts | 40 + packages/sandbox/daemon/git/git-sync.ts | 46 + packages/sandbox/daemon/paths.test.ts | 28 + packages/sandbox/daemon/paths.ts | 10 + packages/sandbox/daemon/probe.ts | 40 + .../sandbox/daemon/process/dev-autostart.ts | 27 + .../daemon/process/run-process.test.ts | 27 + .../sandbox/daemon/process/run-process.ts | 88 ++ .../daemon/process/script-discovery.test.ts | 37 + .../daemon/process/script-discovery.ts | 38 + packages/sandbox/daemon/proxy.ts | 91 ++ packages/sandbox/daemon/routes/bash.test.ts | 48 + packages/sandbox/daemon/routes/bash.ts | 72 ++ .../sandbox/daemon/routes/body-parser.test.ts | 43 + packages/sandbox/daemon/routes/body-parser.ts | 32 + .../sandbox/daemon/routes/events-stream.ts | 28 + packages/sandbox/daemon/routes/exec.test.ts | 67 + packages/sandbox/daemon/routes/exec.ts | 38 + packages/sandbox/daemon/routes/fs.test.ts | 126 ++ packages/sandbox/daemon/routes/fs.ts | 258 ++++ packages/sandbox/daemon/routes/health.test.ts | 47 + packages/sandbox/daemon/routes/health.ts | 16 + packages/sandbox/daemon/routes/kill.ts | 13 + packages/sandbox/daemon/routes/scripts.ts | 7 + packages/sandbox/daemon/setup/branch.test.ts | 60 + packages/sandbox/daemon/setup/branch.ts | 40 + packages/sandbox/daemon/setup/clone.ts | 39 + packages/sandbox/daemon/setup/identity.ts | 22 + packages/sandbox/daemon/setup/install.ts | 45 + .../sandbox/daemon/setup/orchestrator.test.ts | 36 + packages/sandbox/daemon/setup/orchestrator.ts | 116 ++ packages/sandbox/daemon/setup/resume.ts | 10 + packages/sandbox/daemon/types.ts | 39 + packages/sandbox/image/Dockerfile | 40 + .../package.json | 6 +- .../server/daemon-client.test.ts | 152 +-- .../server/daemon-client.ts | 114 +- .../server/docker-cli.ts | 0 .../server/image-build.ts | 19 +- .../server/runner/docker/index.ts | 0 .../runner/docker/local-ingress.test.ts | 103 +- .../server/runner/docker/local-ingress.ts | 30 +- .../server/runner/docker/runner.test.ts | 289 ++++- .../server/runner/docker/runner.ts | 240 ++-- .../server/runner/docker/sweep.ts | 0 .../sandbox/server/runner/freestyle/index.ts | 2 + .../server/runner/freestyle/runner.ts | 128 +- .../server/runner/index.ts | 0 .../server/runner/sandbox-ref.test.ts | 0 .../server/runner/sandbox-ref.ts | 0 .../server/runner/shared/handle.test.ts | 90 ++ .../sandbox/server/runner/shared/handle.ts | 38 + .../server/runner/shared/index.ts | 3 +- .../server/runner/shared/inflight.ts | 0 .../server/runner/shared/lock.ts | 0 .../server/runner/shared/preview-url.ts | 0 .../server/runner/state-store.ts | 0 .../server/runner/types.ts | 0 .../shared.ts | 0 .../tsconfig.json | 0 scripts/dev.ts | 5 +- 158 files changed, 4741 insertions(+), 4173 deletions(-) create mode 100644 apps/mesh/src/api/routes/decopilot/memory.test.ts delete mode 100644 apps/mesh/src/api/routes/sandbox-daemon.test.ts delete mode 100644 apps/mesh/src/api/routes/sandbox-daemon.ts rename apps/mesh/src/{shared => tools/thread}/branch-name.test.ts (100%) rename apps/mesh/src/{shared => tools/thread}/branch-name.ts (100%) create mode 100644 apps/mesh/src/tools/thread/create.test.ts create mode 100644 apps/mesh/src/tools/thread/test-helpers.ts create mode 100644 apps/mesh/src/tools/thread/update.test.ts delete mode 100644 apps/mesh/src/web/hooks/use-create-task-and-navigate.ts create mode 100644 apps/mesh/src/web/hooks/use-ensure-task.ts create mode 100644 apps/mesh/src/web/hooks/use-tasks.ts create mode 100644 apps/mesh/src/web/lib/read-cached-task-branch.ts delete mode 100644 packages/mesh-plugin-user-sandbox/image/Dockerfile delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/auth.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/config.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/deco-watcher.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/dev-process.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/dev-state.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/events.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/exec-process.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/exec-state.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/fs-ops.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/http-helpers.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/image/daemon/workdir.mjs delete mode 100644 packages/mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.ts delete mode 100644 packages/mesh-plugin-user-sandbox/server/runner/freestyle/index.ts delete mode 100644 packages/mesh-plugin-user-sandbox/server/runner/freestyle/runner.test.ts delete mode 100644 packages/mesh-plugin-user-sandbox/server/runner/shared/dev-server.ts delete mode 100644 packages/mesh-plugin-user-sandbox/server/runner/shared/handle.ts create mode 100644 packages/sandbox/.gitignore rename packages/{mesh-plugin-user-sandbox => sandbox}/README.md (67%) create mode 100644 packages/sandbox/daemon/config.test.ts create mode 100644 packages/sandbox/daemon/config.ts create mode 100644 packages/sandbox/daemon/constants.ts rename packages/{mesh-plugin-user-sandbox/server/runner/freestyle/daemon-script.e2e.test.ts => sandbox/daemon/daemon.e2e.test.ts} (78%) create mode 100644 packages/sandbox/daemon/entry.ts create mode 100644 packages/sandbox/daemon/events/broadcast.test.ts create mode 100644 packages/sandbox/daemon/events/broadcast.ts create mode 100644 packages/sandbox/daemon/events/replay.test.ts create mode 100644 packages/sandbox/daemon/events/replay.ts create mode 100644 packages/sandbox/daemon/events/sse-format.ts create mode 100644 packages/sandbox/daemon/events/sse.test.ts create mode 100644 packages/sandbox/daemon/events/sse.ts create mode 100644 packages/sandbox/daemon/git/branch-status.ts create mode 100644 packages/sandbox/daemon/git/git-sync.test.ts create mode 100644 packages/sandbox/daemon/git/git-sync.ts create mode 100644 packages/sandbox/daemon/paths.test.ts create mode 100644 packages/sandbox/daemon/paths.ts create mode 100644 packages/sandbox/daemon/probe.ts create mode 100644 packages/sandbox/daemon/process/dev-autostart.ts create mode 100644 packages/sandbox/daemon/process/run-process.test.ts create mode 100644 packages/sandbox/daemon/process/run-process.ts create mode 100644 packages/sandbox/daemon/process/script-discovery.test.ts create mode 100644 packages/sandbox/daemon/process/script-discovery.ts create mode 100644 packages/sandbox/daemon/proxy.ts create mode 100644 packages/sandbox/daemon/routes/bash.test.ts create mode 100644 packages/sandbox/daemon/routes/bash.ts create mode 100644 packages/sandbox/daemon/routes/body-parser.test.ts create mode 100644 packages/sandbox/daemon/routes/body-parser.ts create mode 100644 packages/sandbox/daemon/routes/events-stream.ts create mode 100644 packages/sandbox/daemon/routes/exec.test.ts create mode 100644 packages/sandbox/daemon/routes/exec.ts create mode 100644 packages/sandbox/daemon/routes/fs.test.ts create mode 100644 packages/sandbox/daemon/routes/fs.ts create mode 100644 packages/sandbox/daemon/routes/health.test.ts create mode 100644 packages/sandbox/daemon/routes/health.ts create mode 100644 packages/sandbox/daemon/routes/kill.ts create mode 100644 packages/sandbox/daemon/routes/scripts.ts create mode 100644 packages/sandbox/daemon/setup/branch.test.ts create mode 100644 packages/sandbox/daemon/setup/branch.ts create mode 100644 packages/sandbox/daemon/setup/clone.ts create mode 100644 packages/sandbox/daemon/setup/identity.ts create mode 100644 packages/sandbox/daemon/setup/install.ts create mode 100644 packages/sandbox/daemon/setup/orchestrator.test.ts create mode 100644 packages/sandbox/daemon/setup/orchestrator.ts create mode 100644 packages/sandbox/daemon/setup/resume.ts create mode 100644 packages/sandbox/daemon/types.ts create mode 100644 packages/sandbox/image/Dockerfile rename packages/{mesh-plugin-user-sandbox => sandbox}/package.json (76%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/daemon-client.test.ts (72%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/daemon-client.ts (50%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/docker-cli.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/image-build.ts (88%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/index.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/local-ingress.test.ts (76%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/local-ingress.ts (85%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/runner.test.ts (64%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/runner.ts (74%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/docker/sweep.ts (100%) create mode 100644 packages/sandbox/server/runner/freestyle/index.ts rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/freestyle/runner.ts (86%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/index.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/sandbox-ref.test.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/sandbox-ref.ts (100%) create mode 100644 packages/sandbox/server/runner/shared/handle.test.ts create mode 100644 packages/sandbox/server/runner/shared/handle.ts rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/shared/index.ts (56%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/shared/inflight.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/shared/lock.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/shared/preview-url.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/state-store.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/server/runner/types.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/shared.ts (100%) rename packages/{mesh-plugin-user-sandbox => sandbox}/tsconfig.json (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8297a29673..78825be2bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,6 +84,9 @@ jobs: - name: Install dependencies run: bun install + - name: Build daemon bundle + run: bun run --cwd=packages/sandbox build + - name: Start NATS with JetStream run: | docker run -d --name nats -p 4222:4222 nats:2.10 -js @@ -135,3 +138,51 @@ jobs: - name: Run knip run: bun run knip + + docker-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install + + - name: Build daemon bundle + run: bun run --cwd=packages/sandbox build + + - name: Build sandbox image + run: | + docker build \ + -t mesh-sandbox:ci \ + -f packages/sandbox/image/Dockerfile \ + packages/sandbox + + - name: Smoke test + run: | + docker run -d --name sandbox-smoke -p 19999:9000 \ + -e DAEMON_TOKEN="$(printf 't%.0s' {1..32})" \ + -e DAEMON_BOOT_ID="ci-smoke" \ + -e APP_ROOT=/app \ + -e PROXY_PORT=9000 \ + -e DAEMON_NO_AUTOSTART=1 \ + mesh-sandbox:ci + for i in $(seq 1 30); do + if curl -fsS http://localhost:19999/health | grep -q '"bootId":"ci-smoke"'; then + echo "ok" + exit 0 + fi + sleep 1 + done + echo "smoke test failed — daemon did not return /health with ci-smoke bootId" + docker logs sandbox-smoke + exit 1 + + - name: Tear down + if: always() + run: docker rm -f sandbox-smoke || true diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 10c9e4fcc5..8a3c1be16c 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -143,7 +143,7 @@ "kysely-pglite": "^0.6.1", "lucide-react": "^0.468.0", "marked": "^15.0.6", - "mesh-plugin-user-sandbox": "workspace:*", + "@decocms/sandbox": "workspace:*", "mesh-plugin-workflows": "workspace:*", "nanoid": "^5.1.6", "pg": "^8.16.3", diff --git a/apps/mesh/spec/monitoring-share-plugin.md b/apps/mesh/spec/monitoring-share-plugin.md index 786e1e6afa..a99159f98d 100644 --- a/apps/mesh/spec/monitoring-share-plugin.md +++ b/apps/mesh/spec/monitoring-share-plugin.md @@ -269,7 +269,7 @@ setup: (ctx) => { |------|--------| | `packages/bindings/src/core/plugins.ts` | Add `rootRoute` and `registerPublicRoutes` to context | | `apps/mesh/src/web/index.tsx` | Pass new context props, collect and mount public routes | -| `packages/mesh-plugin-user-sandbox/client/index.ts` | Migrate connect route registration | +| `packages/@decocms/sandbox/client/index.ts` | Migrate connect route registration | | `apps/mesh/src/web/routes/connect.tsx` | Remove (or keep as fallback) | ### Phase 2 & 3 (Plugin) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 230befbe22..cc1fd882bf 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -45,7 +45,6 @@ import oauthProxyRoutes, { } from "./routes/oauth-proxy"; import openaiCompatRoutes from "./routes/openai-compat"; import proxyRoutes from "./routes/proxy"; -import { createSandboxDaemonRoutes } from "./routes/sandbox-daemon"; import { createKVRoutes } from "./routes/kv"; import { createTriggerCallbackRoutes } from "./routes/trigger-callback"; import publicConfigRoutes from "./routes/public-config"; @@ -1361,10 +1360,6 @@ export async function createApp(options: CreateAppOptions = {}) { }); app.route("/api", decopilotRoutes); - // Daemon control-plane passthrough only — dev-server traffic bypasses - // mesh and hits pods' public URLs directly. - app.route("/", createSandboxDaemonRoutes()); - // Stable file redirect endpoint (resolves mesh-storage: URIs to presigned URLs) app.route("/api", filesRoutes); @@ -1591,7 +1586,7 @@ export async function createApp(options: CreateAppOptions = {}) { const dockerRunner = asDockerRunner(getSharedRunnerIfInit()); if (dockerRunner) { const { sweepDockerOrphansOnShutdown } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); await sweepDockerOrphansOnShutdown(dockerRunner); } diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts index 2be7606f49..adde743946 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts @@ -15,7 +15,7 @@ import { createReadResourceTool } from "./resources"; import { createSandboxTool, type VirtualClient } from "./sandbox"; import { createVmTools } from "./vm-tools"; import { getRunnerByKind } from "@/sandbox/lifecycle"; -import type { RunnerKind } from "mesh-plugin-user-sandbox/runner"; +import type { RunnerKind } from "@decocms/sandbox/runner"; import { createSubtaskTool } from "./subtask"; import { userAskTool } from "./user-ask"; import { proposePlanTool } from "./propose-plan"; diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts index 6e50379356..dc38497264 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts @@ -2,13 +2,13 @@ * VM File Tools — runner-agnostic. * * Registers the six LLM-visible tools (read/write/edit/grep/glob/bash) on - * top of any `SandboxRunner.proxyDaemonRequest`. Path scheme is Docker's - * canonical `/_daemon/fs/` + `/_daemon/bash`; non-Docker runners - * translate inside `proxyDaemonRequest` (see Freestyle's `translateDaemonPath`). + * top of any `SandboxRunner.proxyDaemonRequest`. All runners speak the + * unified `/_decopilot_vm/*` surface with base64-wrapped JSON bodies + * (Cloudflare WAF bypass; harmless 33% overhead on non-CF paths). */ import { tool, zodSchema } from "ai"; -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; import { maybeTruncate } from "./common"; import { BASH_DESCRIPTION, @@ -37,10 +37,13 @@ async function daemonRequest( ): Promise { let res: Response; try { + const b64Body = Buffer.from(JSON.stringify(body), "utf-8").toString( + "base64", + ); res = await runner.proxyDaemonRequest(handle, path, { method: "POST", headers: new Headers({ "content-type": "application/json" }), - body: JSON.stringify(body), + body: b64Body, }); } catch { throw new Error( @@ -98,7 +101,7 @@ export function createVmTools(params: VmToolsParams) { description: READ_DESCRIPTION, inputSchema: zodSchema(ReadInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/read", input); + const result = await call("/_decopilot_vm/read", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -107,14 +110,14 @@ export function createVmTools(params: VmToolsParams) { needsApproval: approvalFor(TOOL_APPROVAL.write), description: WRITE_DESCRIPTION, inputSchema: zodSchema(WriteInputSchema), - execute: async (input) => call("/_daemon/fs/write", input), + execute: async (input) => call("/_decopilot_vm/write", input), }); const edit = tool({ needsApproval: approvalFor(TOOL_APPROVAL.edit), description: EDIT_DESCRIPTION, inputSchema: zodSchema(EditInputSchema), - execute: async (input) => call("/_daemon/fs/edit", input), + execute: async (input) => call("/_decopilot_vm/edit", input), }); const grep = tool({ @@ -122,7 +125,7 @@ export function createVmTools(params: VmToolsParams) { description: GREP_DESCRIPTION, inputSchema: zodSchema(GrepInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/grep", input); + const result = await call("/_decopilot_vm/grep", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -132,7 +135,7 @@ export function createVmTools(params: VmToolsParams) { description: GLOB_DESCRIPTION, inputSchema: zodSchema(GlobInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/glob", input); + const result = await call("/_decopilot_vm/glob", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -142,7 +145,7 @@ export function createVmTools(params: VmToolsParams) { description: BASH_DESCRIPTION, inputSchema: zodSchema(BashInputSchema), execute: async (input) => { - const result = await call("/_daemon/bash", input); + const result = await call("/_decopilot_vm/bash", input); return maybeTruncate(result, toolOutputMap); }, }); diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts index 14df34e823..23bc650b3a 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts @@ -1,4 +1,4 @@ -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; export interface VmToolsParams { readonly runner: SandboxRunner; diff --git a/apps/mesh/src/api/routes/decopilot/memory.test.ts b/apps/mesh/src/api/routes/decopilot/memory.test.ts new file mode 100644 index 0000000000..2d75b7fba8 --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/memory.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { createMemory } from "./memory"; +import { + buildThreadTestContext, + type ThreadTestEnv, +} from "../../../tools/thread/test-helpers"; + +describe("createMemory", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("returns Memory when thread exists", async () => { + const thread = await env.ctx.storage.threads.create({ + id: "thrd_existing", + organization_id: env.orgId, + title: "ok", + created_by: env.userId, + virtual_mcp_id: "vmcp_x", + }); + + const memory = await createMemory(env.ctx.storage.threads, { + thread_id: thread.id, + organization_id: env.orgId, + userId: env.userId, + }); + + expect(memory.thread.id).toBe("thrd_existing"); + }); + + it("throws when thread_id is provided but thread does not exist", async () => { + await expect( + createMemory(env.ctx.storage.threads, { + thread_id: "thrd_does_not_exist", + organization_id: env.orgId, + userId: env.userId, + }), + ).rejects.toThrow(/thread.*not.*found/i); + }); +}); diff --git a/apps/mesh/src/api/routes/decopilot/memory.ts b/apps/mesh/src/api/routes/decopilot/memory.ts index e45f207c48..463b4b0084 100644 --- a/apps/mesh/src/api/routes/decopilot/memory.ts +++ b/apps/mesh/src/api/routes/decopilot/memory.ts @@ -7,14 +7,13 @@ import type { OrgScopedThreadStorage } from "@/storage/threads"; import type { Thread, ThreadMessage } from "@/storage/types"; -import { generatePrefixedId } from "@/shared/utils/generate-id"; /** * Configuration for creating a Memory instance */ export interface MemoryConfig { - /** Thread ID (creates new if not found) */ - thread_id?: string | null; + /** Thread ID (required — thread must exist) */ + thread_id: string; /** Organization scope */ organization_id: string; @@ -24,19 +23,6 @@ export interface MemoryConfig { /** Default window size for pruning */ defaultWindowSize?: number; - - /** Optional trigger ID for automation-created threads */ - triggerId?: string; - - /** Virtual MCP ID to associate with the thread */ - virtualMcpId?: string; - - /** - * Git branch to pin this thread to. Only meaningful for GitHub-linked - * virtualmcps. When set on a brand-new thread, it's persisted on the - * thread row and propagates to VM_START. - */ - branch?: string | null; } /** @@ -88,53 +74,23 @@ export class Memory { } /** - * Create or get a thread, returning a Memory instance + * Get an existing thread by id, returning a Memory instance. + * Throws if the thread does not exist — the route loader is responsible for + * creating threads up-front via COLLECTION_THREADS_CREATE. */ export async function createMemory( storage: OrgScopedThreadStorage, config: MemoryConfig, ): Promise { - const { - thread_id, - organization_id, - userId, - defaultWindowSize, - triggerId, - virtualMcpId, - branch, - } = config; - - let thread: Thread; + const { thread_id, defaultWindowSize } = config; if (!thread_id) { - // Create new thread - thread = await storage.create({ - id: generatePrefixedId("thrd"), - organization_id, - created_by: userId, - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId ?? "", - branch: branch ?? null, - }); - } else { - // Try to get existing thread scoped to this org - const existing = await storage.get(thread_id); + throw new Error("createMemory: thread_id is required"); + } - if (existing) { - thread = existing; - } else { - // Thread not found — create using the client-provided ID so the - // frontend and server stay in sync (avoids a thread-ID switch in - // onFinish which causes a full re-render cascade). - thread = await storage.create({ - id: thread_id, - organization_id, - created_by: userId, - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId ?? "", - branch: branch ?? null, - }); - } + const thread = await storage.get(thread_id); + if (!thread) { + throw new Error(`Thread not found: ${thread_id}`); } return new Memory({ diff --git a/apps/mesh/src/api/routes/decopilot/run-registry.test.ts b/apps/mesh/src/api/routes/decopilot/run-registry.test.ts index 0bcc08f241..a1ee9b7f30 100644 --- a/apps/mesh/src/api/routes/decopilot/run-registry.test.ts +++ b/apps/mesh/src/api/routes/decopilot/run-registry.test.ts @@ -421,7 +421,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -455,7 +455,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -489,7 +489,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -523,7 +523,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -637,7 +637,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: "dead-pod", diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index b1e3f8a50f..4fc725fa86 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -197,6 +197,10 @@ async function streamCoreInner( const windowSize = input.windowSize ?? DEFAULT_WINDOW_SIZE; + if (!input.taskId) { + throw new Error("streamCore: taskId is required"); + } + // 2. Load entities and create/load memory in parallel const [virtualMcp, provider, mem] = await Promise.all([ ctx.storage.virtualMcps.findById(input.agent.id, input.organizationId), @@ -211,9 +215,6 @@ async function streamCoreInner( thread_id: input.taskId, userId: input.userId, defaultWindowSize: windowSize, - triggerId: input.triggerId, - virtualMcpId: input.agent.id, - branch: input.branch ?? null, }), ]); diff --git a/apps/mesh/src/api/routes/sandbox-daemon.test.ts b/apps/mesh/src/api/routes/sandbox-daemon.test.ts deleted file mode 100644 index 820ecd2668..0000000000 --- a/apps/mesh/src/api/routes/sandbox-daemon.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Cross-tenant auth test. The daemon proxy is the only surface through - * which a browser reaches the runner — any leak lets one user reach - * another's container. - */ - -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { Hono } from "hono"; -import type { MeshContext } from "../../core/mesh-context"; - -const proxyDaemonRequest = mock( - async (_handle: string, _path: string, _init: unknown) => - new Response("proxied", { status: 200 }), -); - -const lastRequestedKind: { value: string | null } = { value: null }; - -function makeMockRunner(kind: "docker" | "freestyle") { - return { - kind, - ensure: async () => ({ - handle: "h", - workdir: "/app", - previewUrl: null, - }), - exec: async () => ({ - stdout: "", - stderr: "", - exitCode: 0, - timedOut: false, - }), - delete: async () => {}, - alive: async () => true, - getPreviewUrl: async () => null, - proxyDaemonRequest, - }; -} - -mock.module("@/sandbox/lifecycle", () => ({ - getRunnerByKind: (_ctx: unknown, kind: "docker" | "freestyle") => { - lastRequestedKind.value = kind; - return makeMockRunner(kind); - }, -})); - -const { createSandboxDaemonRoutes } = await import("./sandbox-daemon"); - -type RunnerStateRow = { user_id: string; runner_kind: string }; - -function makeCtxWithRow( - userId: string | null, - row: RunnerStateRow | null, -): MeshContext { - return { - auth: userId - ? { - user: { - id: userId, - email: "t@example.com", - name: "t", - role: "user", - }, - } - : null, - db: { - selectFrom: (_table: string) => ({ - select: (_cols: unknown) => ({ - where: (_col: string, _op: string, _val: string) => ({ - executeTakeFirst: async () => row ?? undefined, - }), - }), - }), - }, - } as unknown as MeshContext; -} - -function mountWithCtx(ctx: MeshContext) { - const app = new Hono<{ Variables: { meshContext: MeshContext } }>(); - app.use("*", async (c, next) => { - c.set("meshContext", ctx); - await next(); - }); - app.route("/", createSandboxDaemonRoutes()); - return app; -} - -describe("sandbox daemon passthrough authorization", () => { - beforeEach(() => { - proxyDaemonRequest.mockClear(); - lastRequestedKind.value = null; - }); - - it("returns 401 when the session has no user", async () => { - const app = mountWithCtx(makeCtxWithRow(null, null)); - const res = await app.request("/api/sandbox/handle_abc/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(401); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("returns 404 when the handle belongs to a different user", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_attacker", { - user_id: "user_victim", - runner_kind: "docker", - }), - ); - const res = await app.request( - "/api/sandbox/handle_victim/_daemon/fs/read", - { method: "POST" }, - ); - expect(res.status).toBe(404); - // Must never forward on ownership mismatch — a leak here lets one user - // reach another's container by knowing the handle. - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("returns 404 when no row exists for the handle", async () => { - const app = mountWithCtx(makeCtxWithRow("user_1", null)); - const res = await app.request( - "/api/sandbox/handle_missing/_daemon/events", - { - method: "GET", - }, - ); - expect(res.status).toBe(404); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("forwards to the runner when the caller owns the handle", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_1", { user_id: "user_1", runner_kind: "docker" }), - ); - const res = await app.request("/api/sandbox/handle_owned/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(200); - expect(proxyDaemonRequest).toHaveBeenCalledTimes(1); - const [handle, path] = proxyDaemonRequest.mock.calls[0]! as [ - string, - string, - unknown, - ]; - expect(handle).toBe("handle_owned"); - expect(path).toBe("/_daemon/fs/read"); - }); - - it("rejects unsupported runner kinds with 400", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_1", { user_id: "user_1", runner_kind: "k8s" }), - ); - const res = await app.request("/api/sandbox/handle_k8s/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(400); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - // Regression guard for the invariant called out in sandbox-daemon.ts:1–5: - // a pod that flipped MESH_SANDBOX_RUNNER after the sandbox row was written - // must still proxy to the kind of runner that owns the container. - it("dispatches on the row's runner_kind even when MESH_SANDBOX_RUNNER env disagrees", async () => { - const original = process.env.MESH_SANDBOX_RUNNER; - process.env.MESH_SANDBOX_RUNNER = "freestyle"; - try { - const app = mountWithCtx( - makeCtxWithRow("user_1", { - user_id: "user_1", - runner_kind: "docker", - }), - ); - const res = await app.request( - "/api/sandbox/handle_owned/_daemon/fs/read", - { method: "POST" }, - ); - expect(res.status).toBe(200); - expect(lastRequestedKind.value).toBe("docker"); - expect(proxyDaemonRequest).toHaveBeenCalledTimes(1); - } finally { - if (original === undefined) delete process.env.MESH_SANDBOX_RUNNER; - else process.env.MESH_SANDBOX_RUNNER = original; - } - }); -}); diff --git a/apps/mesh/src/api/routes/sandbox-daemon.ts b/apps/mesh/src/api/routes/sandbox-daemon.ts deleted file mode 100644 index 6a59a8c61c..0000000000 --- a/apps/mesh/src/api/routes/sandbox-daemon.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Daemon passthrough — auth happens here (session must own the handle). - * Dev-server traffic bypasses this route (pods expose dev directly). - * Runner is dispatched on the row's `runner_kind`, not current env. - */ - -import { Hono } from "hono"; -import type { Context } from "hono"; -import type { - RunnerKind, - SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; -import type { MeshContext } from "@/core/mesh-context"; -import { getRunnerByKind } from "@/sandbox/lifecycle"; - -const SUPPORTED_KINDS: ReadonlySet = new Set([ - "docker", - "freestyle", -]); - -async function authorizeSandbox( - c: Context<{ Variables: { meshContext: MeshContext } }>, -): Promise<{ handle: string; runner: SandboxRunner } | Response> { - const ctx = c.get("meshContext"); - const userId = ctx.auth?.user?.id; - if (!userId) return c.json({ error: "Unauthorized" }, 401); - - const handle = c.req.param("handle"); - if (!handle) return c.json({ error: "Invalid sandbox handle" }, 400); - - const row = await ctx.db - .selectFrom("sandbox_runner_state") - .select(["user_id", "runner_kind"]) - .where("handle", "=", handle) - .executeTakeFirst(); - if (!row || row.user_id !== userId) { - return c.json({ error: "Sandbox not found" }, 404); - } - const kind = row.runner_kind as RunnerKind; - if (!SUPPORTED_KINDS.has(kind)) { - return c.json( - { error: `Daemon passthrough unsupported for runner ${row.runner_kind}` }, - 400, - ); - } - return { handle, runner: await getRunnerByKind(ctx, kind) }; -} - -export function createSandboxDaemonRoutes() { - const app = new Hono<{ Variables: { meshContext: MeshContext } }>(); - - const forward = async ( - c: Context<{ Variables: { meshContext: MeshContext } }>, - ) => { - const auth = await authorizeSandbox(c); - if (auth instanceof Response) return auth; - - const prefix = `/api/sandbox/${auth.handle}`; - const url = new URL(c.req.url); - const tail = url.pathname.startsWith(prefix) - ? url.pathname.slice(prefix.length) - : ""; - // tail already starts with `/_daemon/...`, so passthrough as-is. - const upstream = await auth.runner.proxyDaemonRequest( - auth.handle, - `${tail}${url.search}`, - { - method: c.req.method, - headers: c.req.raw.headers, - body: c.req.raw.body, - signal: c.req.raw.signal, - }, - ); - return new Response(upstream.body, { - status: upstream.status, - statusText: upstream.statusText, - headers: upstream.headers, - }); - }; - - app.all("/api/sandbox/:handle/_daemon/*", forward); - app.all("/api/sandbox/:handle/_daemon", forward); - - return app; -} diff --git a/apps/mesh/src/cli/sandbox-image.ts b/apps/mesh/src/cli/sandbox-image.ts index 8d59d018df..59850b32ac 100644 --- a/apps/mesh/src/cli/sandbox-image.ts +++ b/apps/mesh/src/cli/sandbox-image.ts @@ -16,7 +16,7 @@ export async function kickoffSandboxImageBuild(opts: { if (process.env.MESH_SANDBOX_IMAGE) return; const { tryResolveRunnerKindFromEnv, ensureSandboxImage } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); if (tryResolveRunnerKindFromEnv() !== "docker") return; diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 86d0a45e53..5dbbb47081 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -81,12 +81,10 @@ let ingressServers: import("node:net").Server[] = []; // Docker-only boot/dev wiring. Both hooks (boot sweep + local ingress) are // intimate with Docker-specific primitives (labels, host-port mappings); // other runners manage their own VM/ingress lifecycle. -const { tryResolveRunnerKindFromEnv } = await import( - "mesh-plugin-user-sandbox/runner" -); +const { tryResolveRunnerKindFromEnv } = await import("@decocms/sandbox/runner"); if (tryResolveRunnerKindFromEnv() === "docker") { const { sweepDockerOrphansOnBoot, startLocalSandboxIngress } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); const { asDockerRunner, getSharedRunnerIfInit } = await import( "./sandbox/lifecycle" diff --git a/apps/mesh/src/sandbox/lifecycle.test.ts b/apps/mesh/src/sandbox/lifecycle.test.ts index 257df64a0d..4d47a19883 100644 --- a/apps/mesh/src/sandbox/lifecycle.test.ts +++ b/apps/mesh/src/sandbox/lifecycle.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { DockerSandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import { DockerSandboxRunner } from "@decocms/sandbox/runner"; import type { MeshContext } from "@/core/mesh-context"; import { asDockerRunner, getRunnerByKind } from "./lifecycle"; diff --git a/apps/mesh/src/sandbox/lifecycle.ts b/apps/mesh/src/sandbox/lifecycle.ts index 95ebaf65d2..7c4138458a 100644 --- a/apps/mesh/src/sandbox/lifecycle.ts +++ b/apps/mesh/src/sandbox/lifecycle.ts @@ -13,7 +13,7 @@ import { tryResolveRunnerKindFromEnv, type RunnerKind, type SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import { KyselySandboxRunnerStateStore } from "@/storage/sandbox-runner-state"; const runners: Partial> = {}; @@ -30,7 +30,7 @@ async function instantiate( // Dynamic import — freestyle SDK is an optionalDependency so // docker-only deploys don't need it installed. const { FreestyleSandboxRunner } = await import( - "mesh-plugin-user-sandbox/runner/freestyle" + "@decocms/sandbox/runner/freestyle" ); return new FreestyleSandboxRunner({ stateStore }); } diff --git a/apps/mesh/src/storage/sandbox-runner-state.test.ts b/apps/mesh/src/storage/sandbox-runner-state.test.ts index 84fbc61f98..ba2116a226 100644 --- a/apps/mesh/src/storage/sandbox-runner-state.test.ts +++ b/apps/mesh/src/storage/sandbox-runner-state.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import type { SandboxId } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxId } from "@decocms/sandbox/runner"; import { closeTestDatabase, createTestDatabase, diff --git a/apps/mesh/src/storage/sandbox-runner-state.ts b/apps/mesh/src/storage/sandbox-runner-state.ts index 79be100ea1..df1961825e 100644 --- a/apps/mesh/src/storage/sandbox-runner-state.ts +++ b/apps/mesh/src/storage/sandbox-runner-state.ts @@ -1,7 +1,7 @@ /** * Kysely-backed RunnerStateStore. `state` jsonb is opaque — each runner * serialises its own fields. See - * packages/mesh-plugin-user-sandbox/server/runner/. + * packages/@decocms/sandbox/server/runner/. * * Method implementations take an explicit executor (db or trx) so the scoped * store handed to `withLock` callbacks can reuse the lock's connection. If @@ -19,7 +19,7 @@ import type { RunnerStateStore, RunnerStateStoreOps, SandboxId, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import type { Database } from "./types"; type Executor = Kysely; diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index d3c5cb8459..30d3ffe488 100644 --- a/apps/mesh/src/storage/threads.ts +++ b/apps/mesh/src/storage/threads.ts @@ -155,13 +155,26 @@ export class SqlThreadStorage implements ThreadStoragePort { : {}), }; - const result = await this.db + const inserted = await this.db .insertInto("threads") .values(row) + .onConflict((oc) => oc.column("id").doNothing()) .returningAll() + .executeTakeFirst(); + + if (inserted) { + return this.threadFromDbRow(inserted); + } + + // Conflict — another caller already inserted this id. Return the row that won. + const existing = await this.db + .selectFrom("threads") + .selectAll() + .where("id", "=", id) + .where("organization_id", "=", data.organization_id) .executeTakeFirstOrThrow(); - return this.threadFromDbRow(result); + return this.threadFromDbRow(existing); } async get(id: string, organizationId: string): Promise { @@ -707,7 +720,7 @@ export class SqlThreadStorage implements ThreadStoragePort { created_at: toIsoString(row.created_at), updated_at: toIsoString(row.updated_at), created_by: row.created_by, - updated_by: row.updated_by, + updated_by: row.updated_by ?? undefined, hidden: !!row.hidden, }; } diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index dacd5b5c18..b3a83da9b7 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -775,7 +775,7 @@ export interface Thread { created_at: string; updated_at: string; created_by: string; - updated_by: string | null; + updated_by: string | undefined; hidden: boolean | null; status: ThreadStatus; trigger_id: string | null; diff --git a/apps/mesh/src/shared/branch-name.test.ts b/apps/mesh/src/tools/thread/branch-name.test.ts similarity index 100% rename from apps/mesh/src/shared/branch-name.test.ts rename to apps/mesh/src/tools/thread/branch-name.test.ts diff --git a/apps/mesh/src/shared/branch-name.ts b/apps/mesh/src/tools/thread/branch-name.ts similarity index 100% rename from apps/mesh/src/shared/branch-name.ts rename to apps/mesh/src/tools/thread/branch-name.ts diff --git a/apps/mesh/src/tools/thread/create.test.ts b/apps/mesh/src/tools/thread/create.test.ts new file mode 100644 index 0000000000..0ea8bb410f --- /dev/null +++ b/apps/mesh/src/tools/thread/create.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { COLLECTION_THREADS_CREATE } from "./create"; +import { buildThreadTestContext, type ThreadTestEnv } from "./test-helpers"; + +describe("COLLECTION_THREADS_CREATE", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("assigns a generated branch when the vMCP has a github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toMatch(/^deco\/[a-z]+-[a-z]+$/); + expect(result.item.virtual_mcp_id).toBe(vmcp.id); + }); + + it("leaves branch null when the vMCP has no github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "no-gh", connections: [], status: "active", pinned: false }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toBeNull(); + }); + + it("uses the input branch when the vMCP has a github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp-explicit", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { + data: { + virtual_mcp_id: vmcp.id, + title: "t", + branch: "deco/custom-branch", + }, + }, + env.ctx, + ); + + expect(result.item.branch).toBe("deco/custom-branch"); + }); + + it("ignores the input branch when the vMCP has no github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "no-gh-with-input-branch", + connections: [], + status: "active", + pinned: false, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { + data: { + virtual_mcp_id: vmcp.id, + title: "t", + branch: "deco/should-be-ignored", + }, + }, + env.ctx, + ); + + expect(result.item.branch).toBeNull(); + }); + + it("picks the most-recently-touched vmMap branch when no input branch + github vMCP", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp-with-vmmap", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + vmMap: { + [env.userId]: { + "deco/old-branch": { + vmId: "vm_old", + previewUrl: null, + createdAt: 1000, + }, + "deco/new-branch": { + vmId: "vm_new", + previewUrl: null, + createdAt: 2000, + }, + }, + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toBe("deco/new-branch"); + }); + + it("is idempotent: creating with the same id twice returns the same row", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "x", connections: [], status: "active", pinned: false }, + ); + + const id = "thrd_test_idempotent"; + const first = await COLLECTION_THREADS_CREATE.handler( + { data: { id, virtual_mcp_id: vmcp.id, title: "first" } }, + env.ctx, + ); + const second = await COLLECTION_THREADS_CREATE.handler( + { data: { id, virtual_mcp_id: vmcp.id, title: "second" } }, + env.ctx, + ); + + expect(second.item.id).toBe(first.item.id); + expect(second.item.title).toBe("first"); // existing row, not overwritten + }); +}); diff --git a/apps/mesh/src/tools/thread/create.ts b/apps/mesh/src/tools/thread/create.ts index 3fc0fa575e..5eb26146a3 100644 --- a/apps/mesh/src/tools/thread/create.ts +++ b/apps/mesh/src/tools/thread/create.ts @@ -1,7 +1,18 @@ /** * COLLECTION_THREADS_CREATE Tool * - * Create a new thread (organization-scoped) with collection binding compliance. + * Create a new thread for a virtual MCP. + * + * Branch resolution (only meaningful when the vMCP has a githubRepo): + * 1. Honor `data.branch` when provided. + * 2. Otherwise pick the most-recently-touched branch from the user's + * `vmMap[userId]` so a new task lands on a warm sandbox. + * 3. Fall back to a freshly generated `deco/-` name when the + * user has no vmMap entries for this vMCP. + * + * Threads created on a vMCP without a githubRepo always get `branch = null`. + * + * Idempotent on `id` collisions (storage uses INSERT … ON CONFLICT DO NOTHING). */ import { z } from "zod"; @@ -13,10 +24,8 @@ import { } from "../../core/mesh-context"; import { ThreadCreateDataSchema, ThreadEntitySchema } from "./schema"; import { generatePrefixedId } from "@/shared/utils/generate-id"; +import { generateBranchName } from "./branch-name"; -/** - * Input schema for creating threads (wrapped in data field for collection compliance) - */ const CreateInputSchema = z.object({ data: ThreadCreateDataSchema.describe( "Data for the new thread (id is auto-generated if not provided)", @@ -25,13 +34,38 @@ const CreateInputSchema = z.object({ export type CreateThreadInput = z.infer; -/** - * Output schema for created thread - */ const CreateOutputSchema = z.object({ item: ThreadEntitySchema.describe("The created thread entity"), }); +type GithubRepoMeta = { + githubRepo?: { + owner: string; + name: string; + connectionId?: string; + } | null; +}; + +type VmMapMeta = { + vmMap?: Record>; +}; + +/** + * Pick the user's most-recently-touched branch from vmMap. Returns undefined + * when the user has no entries (caller falls back to generateBranchName). + */ +function pickWarmBranchFromVmMap( + vmMap: VmMapMeta["vmMap"], + userId: string, +): string | undefined { + const entries = vmMap?.[userId]; + if (!entries) return undefined; + const sorted = Object.entries(entries).sort( + ([, a], [, b]) => (b.createdAt ?? 0) - (a.createdAt ?? 0), + ); + return sorted[0]?.[0]; +} + export const COLLECTION_THREADS_CREATE = defineTool({ name: "COLLECTION_THREADS_CREATE", description: "Create a new thread for organizing messages and conversations.", @@ -39,7 +73,7 @@ export const COLLECTION_THREADS_CREATE = defineTool({ title: "Create Thread", readOnlyHint: false, destructiveHint: true, - idempotentHint: false, + idempotentHint: true, openWorldHint: false, }, inputSchema: CreateInputSchema, @@ -48,7 +82,6 @@ export const COLLECTION_THREADS_CREATE = defineTool({ handler: async (input, ctx) => { requireAuth(ctx); const organization = requireOrganization(ctx); - await ctx.access.check(); const userId = getUserId(ctx); @@ -56,14 +89,37 @@ export const COLLECTION_THREADS_CREATE = defineTool({ throw new Error("User ID required to create thread"); } - const taskId = input.data.id ?? generatePrefixedId("thrd"); + const { data } = input; + const taskId = data.id ?? generatePrefixedId("thrd"); + + const vmcp = await ctx.storage.virtualMcps.findById( + data.virtual_mcp_id, + organization.id, + ); + if (!vmcp) { + throw new Error(`Virtual MCP not found: ${data.virtual_mcp_id}`); + } + + const metadata = vmcp.metadata as + | (GithubRepoMeta & VmMapMeta) + | null + | undefined; + const githubRepo = metadata?.githubRepo; + let branch: string | null = null; + if (githubRepo) { + branch = + data.branch ?? + pickWarmBranchFromVmMap(metadata?.vmMap, userId) ?? + generateBranchName(); + } const result = await ctx.storage.threads.create({ id: taskId, organization_id: organization.id, - title: input.data.title, - description: input.data.description, - branch: input.data.branch ?? null, + title: data.title, + description: data.description, + virtual_mcp_id: data.virtual_mcp_id, + branch, created_by: userId, }); diff --git a/apps/mesh/src/tools/thread/helpers.test.ts b/apps/mesh/src/tools/thread/helpers.test.ts index 1b555eab07..da1b005614 100644 --- a/apps/mesh/src/tools/thread/helpers.test.ts +++ b/apps/mesh/src/tools/thread/helpers.test.ts @@ -14,7 +14,7 @@ const BASE_THREAD: Thread = { created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", created_by: "user_test", - updated_by: null, + updated_by: undefined, hidden: null, status: "completed", trigger_id: null, diff --git a/apps/mesh/src/tools/thread/schema.ts b/apps/mesh/src/tools/thread/schema.ts index 27c5950030..cf30f15172 100644 --- a/apps/mesh/src/tools/thread/schema.ts +++ b/apps/mesh/src/tools/thread/schema.ts @@ -68,7 +68,7 @@ export const ThreadEntitySchema = z.object({ created_by: z.string().describe("User ID who created the thread"), updated_by: z .string() - .nullable() + .optional() .describe("User ID who last updated the thread"), virtual_mcp_id: z .string() @@ -100,12 +100,18 @@ export type ThreadEntity = z.infer; export const ThreadCreateDataSchema = z.object({ id: z.string().optional().describe("Optional custom ID for the thread"), - title: z.string().describe("Thread title"), + title: z.string().optional().describe("Thread title"), description: z.string().nullish().describe("Thread description"), + virtual_mcp_id: z + .string() + .describe("Virtual MCP (agent) this thread is bound to"), branch: z .string() - .nullish() - .describe("Git branch to pin this thread to (GitHub-linked vms only)"), + .min(1) + .optional() + .describe( + "Preferred branch. Used only when the vMCP has a githubRepo; ignored otherwise. When omitted, the server picks the most-recently-touched branch from the user's vmMap, falling back to a freshly generated name.", + ), }); export type ThreadCreateData = z.infer; diff --git a/apps/mesh/src/tools/thread/test-helpers.ts b/apps/mesh/src/tools/thread/test-helpers.ts new file mode 100644 index 0000000000..cecc236ce0 --- /dev/null +++ b/apps/mesh/src/tools/thread/test-helpers.ts @@ -0,0 +1,144 @@ +/** + * Test scaffolding for thread tool tests. Mirrors the manual context + * construction in `connection/connection-tools.test.ts`, but only wires the + * storage modules the thread tools touch (threads, virtualMcps). + */ + +import { vi } from "bun:test"; +import { + createTestDatabase, + closeTestDatabase, + type TestDatabase, +} from "../../database/test-db"; +import { + createTestSchema, + seedCommonTestFixtures, +} from "../../storage/test-helpers"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { + SqlThreadStorage, + OrgScopedThreadStorage, +} from "../../storage/threads"; +import { VirtualMCPStorage } from "../../storage/virtual"; +import type { BoundAuthClient, MeshContext } from "../../core/mesh-context"; + +const ORG_ID = "org_test"; +const USER_ID = "user_test"; + +export interface ThreadTestEnv { + database: TestDatabase; + ctx: MeshContext; + orgId: string; + userId: string; + close: () => Promise; +} + +const createMockBoundAuth = (): BoundAuthClient => + ({ + hasPermission: vi.fn().mockResolvedValue(true), + organization: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + get: vi.fn(), + list: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + listMembers: vi.fn(), + updateMemberRole: vi.fn(), + }, + apiKey: { + create: vi.fn(), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }) as unknown as BoundAuthClient; + +export async function buildThreadTestContext(): Promise { + const database = await createTestDatabase(); + await createTestSchema(database.db); + await seedCommonTestFixtures(database.db); + + const vault = new CredentialVault(CredentialVault.generateKey()); + const sqlThreads = new SqlThreadStorage(database.db); + const threads = new OrgScopedThreadStorage(sqlThreads, ORG_ID); + const virtualMcps = new VirtualMCPStorage(database.db); + + const ctx = { + timings: { + measure: async (_name: string, cb: () => Promise) => await cb(), + }, + auth: { + user: { + id: USER_ID, + email: "[email protected]", + name: "T", + role: "admin", + }, + }, + organization: { id: ORG_ID, slug: "test-org", name: "Test Org" }, + storage: { + threads, + virtualMcps, + // Stub the rest — thread tools don't touch these. + connections: null as never, + organizationSettings: null as never, + monitoring: null as never, + users: null as never, + tags: null as never, + virtualMcpPluginConfigs: null as never, + aiProviderKeys: null as never, + oauthPkceStates: null as never, + automations: null as never, + orgSsoConfig: null as never, + orgSsoSessions: null as never, + triggerCallbackTokens: null as never, + registry: null as never, + brandContext: null as never, + organizationDomains: null as never, + }, + vault, + authInstance: null as never, + boundAuth: createMockBoundAuth(), + access: { + granted: () => true, + check: async () => {}, + grant: () => {}, + setToolName: () => {}, + } as never, + db: database.db, + tracer: { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => + fn({ + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + }), + } as never, + meter: { + createHistogram: () => ({ record: () => {} }), + createCounter: () => ({ add: () => {} }), + } as never, + baseUrl: "https://mesh.example.com", + metadata: { requestId: "req_test", timestamp: new Date() }, + eventBus: null as never, + objectStorage: null as never, + aiProviders: null as never, + createMCPProxy: vi.fn().mockResolvedValue({}), + getOrCreateClient: vi.fn().mockResolvedValue({}), + pendingRevalidations: [], + } as unknown as MeshContext; + + return { + database, + ctx, + orgId: ORG_ID, + userId: USER_ID, + close: () => closeTestDatabase(database), + }; +} diff --git a/apps/mesh/src/tools/thread/update.test.ts b/apps/mesh/src/tools/thread/update.test.ts new file mode 100644 index 0000000000..23b740a4d1 --- /dev/null +++ b/apps/mesh/src/tools/thread/update.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { COLLECTION_THREADS_CREATE } from "./create"; +import { COLLECTION_THREADS_UPDATE } from "./update"; +import { buildThreadTestContext, type ThreadTestEnv } from "./test-helpers"; + +describe("COLLECTION_THREADS_UPDATE", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("rejects branch=null for a github-linked thread", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "a", + name: "b", + url: "https://github.com/a/b", + installationId: 1, + connectionId: "c", + }, + }, + }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + await expect( + COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: null } }, + env.ctx, + ), + ).rejects.toThrow(/branch.*null.*github/i); + }); + + it("allows branch=null for non-github threads", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "no-gh", connections: [], status: "active", pinned: false }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + const updated = await COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: null } }, + env.ctx, + ); + expect(updated.item.branch).toBeNull(); + }); + + it("allows switching to a different branch on github threads", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "a", + name: "b", + url: "https://github.com/a/b", + installationId: 1, + connectionId: "c", + }, + }, + }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + const updated = await COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: "deco/manual-pick" } }, + env.ctx, + ); + expect(updated.item.branch).toBe("deco/manual-pick"); + }); +}); diff --git a/apps/mesh/src/tools/thread/update.ts b/apps/mesh/src/tools/thread/update.ts index 932ec09b66..df182225fe 100644 --- a/apps/mesh/src/tools/thread/update.ts +++ b/apps/mesh/src/tools/thread/update.ts @@ -60,6 +60,27 @@ export const COLLECTION_THREADS_UPDATE = defineTool({ throw new Error("Thread not found in organization"); } + if (data.branch === null && existing.virtual_mcp_id) { + const vmcp = await ctx.storage.virtualMcps.findById( + existing.virtual_mcp_id, + requireOrganization(ctx).id, + ); + type GithubRepoMeta = { + githubRepo?: { + owner: string; + name: string; + connectionId?: string; + } | null; + }; + const githubRepo = (vmcp?.metadata as GithubRepoMeta | null | undefined) + ?.githubRepo; + if (githubRepo) { + throw new Error( + "Cannot set branch=null on a github-linked thread (vMCP has githubRepo)", + ); + } + } + const updateData: Parameters[1] = { title: data.title, description: data.description, diff --git a/apps/mesh/src/tools/vm/start.test.ts b/apps/mesh/src/tools/vm/start.test.ts index f8e3d783b2..721abeb877 100644 --- a/apps/mesh/src/tools/vm/start.test.ts +++ b/apps/mesh/src/tools/vm/start.test.ts @@ -6,8 +6,8 @@ import type { Sandbox, SandboxId, SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; -import { composeSandboxRef } from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; +import { composeSandboxRef } from "@decocms/sandbox/runner"; // Pin runner kind — the dev env flips MESH_SANDBOX_RUNNER and VM_START // reads it at handler time. diff --git a/apps/mesh/src/tools/vm/start.ts b/apps/mesh/src/tools/vm/start.ts index 6bbf815378..19baeacd9e 100644 --- a/apps/mesh/src/tools/vm/start.ts +++ b/apps/mesh/src/tools/vm/start.ts @@ -16,13 +16,13 @@ import { resolveRunnerKindFromEnv, type RunnerKind, type Workload, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import { defineTool } from "../../core/define-tool"; import type { MeshContext } from "../../core/mesh-context"; import { requireVmEntry, resolveRuntimeConfig } from "./helpers"; import { buildCloneInfo } from "../../shared/github-clone-info"; import { detectRepoRuntime } from "../../shared/github-runtime-detect"; -import { generateBranchName } from "../../shared/branch-name"; +import { generateBranchName } from "../thread/branch-name"; import { PACKAGE_MANAGER_CONFIG } from "../../shared/runtime-defaults"; import { getRunnerByKind, getSharedRunner } from "../../sandbox/lifecycle"; import { setVmMapEntry } from "./vm-map"; diff --git a/apps/mesh/src/tools/vm/stop.test.ts b/apps/mesh/src/tools/vm/stop.test.ts index 0887764884..1543083845 100644 --- a/apps/mesh/src/tools/vm/stop.test.ts +++ b/apps/mesh/src/tools/vm/stop.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; import type { VmMap, VmMapEntry } from "@decocms/mesh-sdk"; import type { MeshContext } from "../../core/mesh-context"; -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; // Mock per-kind runner lookup BEFORE importing VM_DELETE. const mockDelete = mock(async (_handle: string): Promise => {}); @@ -50,7 +50,7 @@ const FREESTYLE_ENTRY: VmMapEntry = { const DOCKER_ENTRY: VmMapEntry = { vmId: "f9e2fadeb813e08eb00eef6f962be2b2", - previewUrl: "http://f9e2.sandboxes.localhost:7070/", + previewUrl: "http://f9e2.localhost:7070/", runnerKind: "docker", }; diff --git a/apps/mesh/src/tools/vm/stop.ts b/apps/mesh/src/tools/vm/stop.ts index 1b4db473d3..1175384e87 100644 --- a/apps/mesh/src/tools/vm/stop.ts +++ b/apps/mesh/src/tools/vm/stop.ts @@ -5,7 +5,7 @@ */ import { z } from "zod"; -import type { RunnerKind } from "mesh-plugin-user-sandbox/runner"; +import type { RunnerKind } from "@decocms/sandbox/runner"; import { defineTool } from "../../core/define-tool"; import { requireVmEntry } from "./helpers"; import { getRunnerByKind } from "../../sandbox/lifecycle"; diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index 91046d601c..1c278e5d96 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -52,6 +52,7 @@ import { toMetadataModelInfo } from "../../lib/metadata-model-info"; import { useChatNavigation } from "./hooks/use-chat-navigation"; import { useStreamManager } from "./hooks/use-stream-manager"; +import { useTaskActions } from "../../hooks/use-tasks"; import { useTaskManager, type TaskOwnerFilter } from "./task"; import { useTaskMessages } from "./task/use-task-manager"; import { derivePartsFromTiptapDoc } from "./derive-parts"; @@ -105,14 +106,14 @@ export interface ChatTaskContextValue { hideTask: (taskId: string) => Promise; renameTask: (taskId: string, title: string) => Promise; setTaskStatus: (taskId: string, status: string) => Promise; - /** Derived from thread.branch, falling back to `?branch=` for fresh threads. */ + /** thread.branch — the only source of truth. Null until the user picks one or the server generates one on first send. */ currentBranch: string | null; /** * Immutable once set: switching branches mid-conversation would reroute the * thread's vmMap entry, so users must create a new thread for another branch. */ isBranchLocked: boolean; - /** Persist pinned branch and sync URL so it survives cross-thread navigation. */ + /** Persist pinned branch onto the thread (cache + server). */ setCurrentTaskBranch: (branch: string | null) => void; ownerFilter: TaskOwnerFilter; setOwnerFilter: (filter: TaskOwnerFilter) => void; @@ -149,8 +150,6 @@ export interface ChatPrefsContextValue { setTiptapDoc: (doc: Metadata["tiptapDoc"]) => void; /** @deprecated Use tiptapDoc directly */ tiptapDocRef: { current: Metadata["tiptapDoc"] }; - /** Set ephemeral per-task agent override. Passing null resets to URL agent. */ - setVirtualMcpId: (id: string | null) => void; /** @deprecated No-op */ resetInteraction: () => void; /** Whether Simple Model Mode is enabled for the org */ @@ -234,11 +233,8 @@ export function ChatContextProvider({ // URL state const { taskId: urlTaskId, - virtualMcpOverride, - branch: urlBranch, + virtualMcpId: urlVirtualMcpId, navigateToTask: rawNavigateToTask, - setVirtualMcpOverride, - setBranch, } = useChatNavigation(); // Preferences @@ -395,8 +391,8 @@ export function ChatContextProvider({ // taskId always comes from the URL (seeded by router's validateSearch) const effectiveTaskId = urlTaskId; - // Effective agent: URL override (ephemeral per-task) ?? path param (thread owner) - const effectiveVirtualMcpId = virtualMcpOverride ?? virtualMcpId; + // Effective agent: URL param ?? prop (thread owner) + const effectiveVirtualMcpId = urlVirtualMcpId; // Single-item fetch for the selected virtual MCP (no full list needed) const selectedVirtualMcpData = useVirtualMCP(effectiveVirtualMcpId); @@ -512,46 +508,66 @@ export function ChatContextProvider({ const clearPendingMessage = () => setPendingMessage(null); - // Atomically syncs URL `?branch=` to thread.branch so the preview iframe - // picks the right vmMap entry on first paint (no flicker through unset-branch). - const navigateToTask = ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => { + const navigateToTask = (taskId: string, opts?: { virtualMcpId?: string }) => { markTaskRead(taskId); - const task = tasks.find((t) => t.id === taskId); - const resolvedBranch = - opts?.branch !== undefined ? opts.branch : (task?.branch ?? null); rawNavigateToTask(taskId, { - virtualMcpOverride: opts?.virtualMcpOverride, - branch: resolvedBranch, + virtualMcpId: opts?.virtualMcpId, }); }; - // thread.branch is authoritative; URL `?branch=` is only a seed for fresh tasks. const activeTask = tasks.find((t) => t.id === effectiveTaskId); - const currentBranch = activeTask?.branch ?? urlBranch ?? null; + const currentBranch = activeTask?.branch ?? null; const isBranchLocked = !!activeTask?.branch; - // Create task (optimistic + navigate), returns new task ID + // Create task — calls COLLECTION_THREADS_CREATE up-front with the active + // task's branch so the new thread lands on the same warm sandbox. The + // route loader's useEnsureTask will see the row already exists on its + // GET and skip the create-on-404 fallback. + const taskActions = useTaskActions(); const createTask = (): string => { - const newId = taskManager.createTask(); - navigateToTask(newId); + const newId = crypto.randomUUID(); + void taskActions.create + .mutateAsync({ + id: newId, + virtual_mcp_id: virtualMcpId, + ...(currentBranch ? { branch: currentBranch } : {}), + } as Partial) + .then(() => navigateToTask(newId)) + .catch(() => { + // create error toast already fired by useCollectionActions; navigate + // anyway so the user's not stranded — the route loader's ensure + // fallback will retry. + navigateToTask(newId); + }); return newId; }; - // Create task + queue a pending message for ActiveTaskProvider to consume + // Create task + queue a pending message. Propagates currentBranch only + // when the new task is on the same vMCP (different vMCPs have their own + // vmMap, so carrying a branch across them would land on a cold sandbox). const createTaskWithMessage = (params: { message: SendMessageParams; virtualMcpId?: string; }) => { - const newId = taskManager.createTask(); - navigateToTask(newId, { - virtualMcpOverride: - params.virtualMcpId && params.virtualMcpId !== virtualMcpId - ? params.virtualMcpId - : undefined, - }); + const newId = crypto.randomUUID(); + const targetVmcp = params.virtualMcpId ?? virtualMcpId; + const carryBranch = targetVmcp === virtualMcpId ? currentBranch : null; + void taskActions.create + .mutateAsync({ + id: newId, + virtual_mcp_id: targetVmcp, + ...(carryBranch ? { branch: carryBranch } : {}), + } as Partial) + .then(() => + navigateToTask(newId, { + virtualMcpId: params.virtualMcpId, + }), + ) + .catch(() => { + navigateToTask(newId, { + virtualMcpId: params.virtualMcpId, + }); + }); setPendingMessage({ taskId: newId, message: params.message, @@ -587,9 +603,6 @@ export function ChatContextProvider({ currentBranch, isBranchLocked, setCurrentTaskBranch: (branch: string | null) => { - // URL first so the preview panel picks up the new vmMap entry this render; - // thread persistence follows for subsequent navigations back to this thread. - setBranch(branch); if (effectiveTaskId) { taskManager.setTaskBranch(effectiveTaskId, branch); } @@ -628,7 +641,6 @@ export function ChatContextProvider({ tiptapDoc, setTiptapDoc, tiptapDocRef, - setVirtualMcpId: setVirtualMcpOverride, resetInteraction: () => {}, simpleModeEnabled: simpleMode.enabled, simpleModeTier, diff --git a/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts b/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts index 5e4424eaef..3d9190549b 100644 --- a/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts +++ b/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts @@ -1,116 +1,45 @@ -/** - * useChatNavigation — URL-driven chat state. - * - * Reads taskId from path params and virtualmcpid from search params. - * virtualMcpId is never null — defaults to the well-known decopilot virtual MCP. - * virtualMcpOverride is an optional search param for ephemeral per-task agent switching. - */ - import { useRef } from "react"; import { getWellKnownDecopilotVirtualMCP } from "@decocms/mesh-sdk"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useProjectContext } from "@decocms/mesh-sdk"; export interface ChatNavigation { + /** Resolved vMCP for the current chat — either the URL param or the well-known decopilot. */ virtualMcpId: string; - virtualMcpOverride: string | undefined; - /** Always defined — resolved from the `/$org/$taskId` path param. */ + /** Always defined — `/$org/$taskId` path param, or a stable fallback for routes that don't have it. */ taskId: string; - /** - * Git branch for this thread (from `?branch=` URL search param). Undefined - * when not set; the server only persists it on thread creation. - */ - branch: string | undefined; - navigateToTask: ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => void; - setVirtualMcpOverride: (id: string | null) => void; - setBranch: (branch: string | null) => void; + /** Navigate to a task. `virtualMcpId` becomes `?virtualmcpid=` — used as bootstrap for the route loader. */ + navigateToTask: (taskId: string, opts?: { virtualMcpId?: string }) => void; } export function useChatNavigation(): ChatNavigation { const navigate = useNavigate(); const { org } = useProjectContext(); - const search = useSearch({ strict: false }) as { - virtualmcpid?: string; - virtualMcpOverride?: string; - branch?: string; - }; - - const routeParams = useParams({ strict: false }) as { - org?: string; - taskId?: string; - }; + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + const routeParams = useParams({ strict: false }) as { taskId?: string }; const virtualMcpId = search.virtualmcpid ?? getWellKnownDecopilotVirtualMCP(org.id).id; - const navigateToTask = ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => { - // Reset panel state — only preserve virtualmcpid + tasks panel visibility. - // This ensures panel layout defaults kick in for the new task. + const navigateToTask = (taskId: string, opts?: { virtualMcpId?: string }) => { navigate({ to: "/$org/$taskId", params: { org: org.slug, taskId }, search: (prev: Record) => { const next: Record = {}; - if (prev.virtualmcpid) next.virtualmcpid = prev.virtualmcpid; + const vmcp = opts?.virtualMcpId ?? prev.virtualmcpid; + if (vmcp) next.virtualmcpid = vmcp; if (prev.tasks) next.tasks = prev.tasks; - if (opts?.virtualMcpOverride) { - next.virtualMcpOverride = opts.virtualMcpOverride; - } - if (opts?.branch) { - next.branch = opts.branch; - } return next; }, }); }; - const setBranch = (branch: string | null) => { - navigate({ - search: (prev: Record) => { - const next = { ...prev }; - if (branch) { - next.branch = branch; - } else { - delete next.branch; - } - return next; - }, - } as never); - }; - - const setVirtualMcpOverride = (id: string | null) => { - navigate({ - search: (prev: Record) => { - const next = { ...prev }; - if (id) { - next.virtualMcpOverride = id; - } else { - delete next.virtualMcpOverride; - } - return next; - }, - } as never); - }; - // On unified chat routes the taskId is a path param. // On other routes (e.g. settings) Chat.Provider still mounts but taskId is // absent — fall back to a stable generated ID so the provider works everywhere. const fallbackRef = useRef(crypto.randomUUID()); const taskId = routeParams.taskId ?? fallbackRef.current; - return { - virtualMcpId, - virtualMcpOverride: search.virtualMcpOverride, - taskId, - branch: search.branch, - navigateToTask, - setVirtualMcpOverride, - setBranch, - }; + return { virtualMcpId, taskId, navigateToTask }; } diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index aadb86c2be..4ad131a318 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -16,10 +16,9 @@ import { Suspense, useState } from "react"; import { ErrorBoundary } from "../error-boundary"; import { Chat } from "./index"; -import { useChatStream, useChatPrefs } from "./context"; +import { useChatStream, useChatPrefs, useChatTask } from "./context"; import { ChatContextPanel } from "./context-panel"; import { wasCreditsEmptyDismissed } from "./credits-empty-state"; -import { useChatNavigation } from "./hooks/use-chat-navigation.ts"; import { BranchPicker } from "../thread/github/branch-picker.tsx"; import { useAiProviderKeys } from "@/web/hooks/collections/use-ai-providers"; @@ -206,7 +205,7 @@ function SidebarEmptyState() { const { org } = useProjectContext(); const { selectedVirtualMcp } = useChatPrefs(); const { data: session } = authClient.useSession(); - const { branch, setBranch } = useChatNavigation(); + const { currentBranch, setCurrentTaskBranch } = useChatTask(); const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); const displayAgent = selectedVirtualMcp ?? defaultAgent; @@ -242,8 +241,8 @@ function SidebarEmptyState() { owner={githubRepo.owner} repo={githubRepo.name} vmMap={fullVm?.metadata?.vmMap} - value={branch} - onChange={setBranch} + value={currentBranch ?? undefined} + onChange={setCurrentTaskBranch} /> )} diff --git a/apps/mesh/src/web/components/chat/task/cache-operations.ts b/apps/mesh/src/web/components/chat/task/cache-operations.ts index 7a9fb61923..4e95d454a0 100644 --- a/apps/mesh/src/web/components/chat/task/cache-operations.ts +++ b/apps/mesh/src/web/components/chat/task/cache-operations.ts @@ -7,13 +7,6 @@ import { KEYS } from "../../../lib/query-keys"; import type { ChatMessage, Task, TasksQueryData } from "./types.ts"; import { TASK_CONSTANTS } from "./types.ts"; -export interface TaskCacheFilters { - owner: "me" | "automation" | "all"; - status: "open" | "archived"; - virtualMcpId?: string; - userId?: string | null; -} - /** * Update task across every cached task list where it appears. * Returns true if the task was found (and updated) in any cache entry. @@ -41,6 +34,7 @@ export function updateTaskInCache( updated_at: updates.updated_at ?? current.updated_at, hidden: updates.hidden ?? current.hidden, status: updates.status ?? current.status, + branch: "branch" in updates ? updates.branch : current.branch, }; const items = [...data.items]; @@ -52,41 +46,6 @@ export function updateTaskInCache( return found; } -/** - * Add task optimistically to the cache - */ -export function addTaskToCache( - queryClient: QueryClient, - locator: string, - task: Task, - filters: TaskCacheFilters, -): void { - const queryKey = KEYS.tasks(locator, filters); - - const currentData = queryClient.getQueryData(queryKey); - - if (!currentData) { - queryClient.setQueryData(queryKey, { - items: [task], - hasMore: false, - totalCount: 1, - }); - return; - } - - // Check if task already exists in cache - const taskExists = currentData.items.some((t) => t.id === task.id); - if (taskExists) { - return; - } - - queryClient.setQueryData(queryKey, { - ...currentData, - items: [task, ...currentData.items], - totalCount: (currentData.totalCount ?? currentData.items.length) + 1, - }); -} - /** * Update messages cache for a task with new messages * Populates the cache directly without refetching from backend diff --git a/apps/mesh/src/web/components/chat/task/helpers.ts b/apps/mesh/src/web/components/chat/task/helpers.ts index 37b8a34d10..818c6e5989 100644 --- a/apps/mesh/src/web/components/chat/task/helpers.ts +++ b/apps/mesh/src/web/components/chat/task/helpers.ts @@ -38,23 +38,3 @@ export async function callUpdateTaskTool( }); return payload.item; } - -/** - * Build an optimistic task object for immediate cache insertion - */ -export function buildOptimisticTask( - id: string, - virtualMcpId?: string, - branch?: string | null, -): Task { - const now = new Date().toISOString(); - return { - id, - title: "New chat", - status: "completed", - created_at: now, - updated_at: now, - virtual_mcp_id: virtualMcpId, - branch: branch ?? null, - }; -} diff --git a/apps/mesh/src/web/components/chat/task/use-task-manager.ts b/apps/mesh/src/web/components/chat/task/use-task-manager.ts index dcbe37d80d..5efef4ab2f 100644 --- a/apps/mesh/src/web/components/chat/task/use-task-manager.ts +++ b/apps/mesh/src/web/components/chat/task/use-task-manager.ts @@ -18,17 +18,11 @@ import { import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { authClient } from "../../../lib/auth-client"; -import { useCollectionCachePrefill } from "../../../hooks/use-collection-cache-prefill"; import { LOCALSTORAGE_KEYS } from "../../../lib/localstorage-keys"; import { KEYS } from "../../../lib/query-keys"; import { useDecopilotEvents } from "../../../hooks/use-decopilot-events"; -import { - addTaskToCache, - updateMessagesCache, - updateTaskInCache, -} from "./cache-operations.ts"; -import { buildOptimisticTask, callUpdateTaskTool } from "./helpers.ts"; -import { useChatNavigation } from "../hooks/use-chat-navigation.ts"; +import { updateMessagesCache, updateTaskInCache } from "./cache-operations.ts"; +import { callUpdateTaskTool } from "./helpers.ts"; import { useState, useTransition } from "react"; import type { ChatMessage, Task } from "./types.ts"; import { TASK_CONSTANTS } from "./types.ts"; @@ -135,9 +129,6 @@ export function useTaskMessages(taskId: string | null) { export function useTaskManager(virtualMcpId: string) { const { locator, org } = useProjectContext(); const queryClient = useQueryClient(); - const { prefillCollectionCache } = useCollectionCachePrefill(); - - const { branch } = useChatNavigation(); const { data: session } = authClient.useSession(); const userId = session?.user?.id; @@ -197,25 +188,6 @@ export function useTaskManager(virtualMcpId: string) { orgId: org.id, }); - // Create task (optimistic + cache) - const createTask = (): string => { - const newTaskId = crypto.randomUUID(); - const optimisticTask = buildOptimisticTask(newTaskId, virtualMcpId, branch); - addTaskToCache(queryClient, locator, optimisticTask, { - owner: ownerFilter, - status: "open", - virtualMcpId, - userId: ownerFilter === "me" ? (userId ?? null) : null, - }); - if (client) { - prefillCollectionCache(client, "THREAD_MESSAGES", org.id, { - filters: [{ column: "thread_id", value: newTaskId }], - pageSize: TASK_CONSTANTS.TASK_MESSAGES_PAGE_SIZE, - }); - } - return newTaskId; - }; - // Update task in cache (across all matching task lists) const updateTask = (taskId: string, updates: Partial) => { updateTaskInCache(queryClient, locator, taskId, updates); @@ -244,19 +216,16 @@ export function useTaskManager(virtualMcpId: string) { } }; - // thread.branch is source of truth for vmMap[userId][branch] resolution, so - // picker changes must land here + URL. No-ops for cache-only threads — the - // branch gets written on first createMemory call. + // Persist the picked branch on the thread row. Server enforces that + // github-linked threads cannot have branch=null; surface that error. const setTaskBranch = async (taskId: string, branch: string | null) => { - updateTaskInCache(queryClient, locator, taskId, { branch }); try { await callUpdateTaskTool(client, taskId, { branch }); + updateTaskInCache(queryClient, locator, taskId, { branch }); } catch (error) { const err = error as Error; - // Fresh thread may not exist server-side yet; cache update is enough. - if (!/not found/i.test(err.message)) { - console.error("[chat] Failed to persist task branch:", error); - } + toast.error(`Failed to update branch: ${err.message}`); + console.error("[chat] setTaskBranch:", error); } }; @@ -330,7 +299,6 @@ export function useTaskManager(virtualMcpId: string) { ownerFilter, setOwnerFilter, isFilterChangePending, - createTask, updateTask, renameTask, hideTask, diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 8f88fa5353..e5ef3bc50c 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -1,6 +1,13 @@ import { Suspense, useState, useRef } from "react"; import { createPortal } from "react-dom"; -import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { + Link, + useNavigate, + useParams, + useRouterState, + useSearch, +} from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { DndContext, closestCenter, @@ -57,7 +64,6 @@ import { import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { usePinnedAgents } from "@/web/hooks/use-pinned-agents"; import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; -import { useCreateTaskAndNavigate } from "@/web/hooks/use-create-task-and-navigate"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { AgentAvatar } from "@/web/components/agent-icon"; import { GitHubIcon } from "@/web/components/icons/github-icon"; @@ -68,6 +74,47 @@ import { GitHubRepoPicker } from "@/web/components/github-repo-picker.tsx"; import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; +import { useTaskActions } from "@/web/hooks/use-tasks"; +import { readCachedTaskBranch } from "@/web/lib/read-cached-task-branch"; + +/** + * Hook for sidebar "spawn task on this vMCP" buttons. When the user clicks + * a vMCP that matches the URL's current virtualmcpid, the active task's + * branch is carried into the new thread so the new task lands on the same + * warm sandbox. When the clicked vMCP differs, no branch is passed and the + * server picks the most-recently-touched vmMap entry for that vMCP. + */ +function useNavigateToNewTaskWithBranchCarry(orgSlug: string) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const taskActions = useTaskActions(); + const { locator } = useProjectContext(); + const params = useParams({ strict: false }) as { taskId?: string }; + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + + return async (clickedVirtualMcpId: string) => { + const taskId = crypto.randomUUID(); + const carryBranch = + clickedVirtualMcpId === search.virtualmcpid + ? readCachedTaskBranch(queryClient, locator, params.taskId ?? "") + : null; + try { + await taskActions.create.mutateAsync({ + id: taskId, + virtual_mcp_id: clickedVirtualMcpId, + ...(carryBranch ? { branch: carryBranch } : {}), + }); + } catch { + // Toast already fired; navigate anyway so the route loader's + // ensure-fallback can retry. + } + navigate({ + to: "/$org/$taskId", + params: { org: orgSlug, taskId }, + search: { virtualmcpid: clickedVirtualMcpId }, + }); + }; +} function AgentListItem({ agent, org, @@ -81,7 +128,7 @@ function AgentListItem({ }) { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); - const navigateToNewTask = useCreateTaskAndNavigate(); + const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org); const pathname = useRouterState({ select: (s) => s.location.pathname }); const isActive = pathname.startsWith(`/${org}/${agent.id}`); const [buttonRect, setButtonRect] = useState(null); @@ -306,7 +353,7 @@ function PinAgentPopoverContent({ }); const [preferences] = usePreferences(); - const navigateToNewTask = useCreateTaskAndNavigate(); + const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org.slug); const navigateToAgent = useNavigateToAgent(); const lowerSearch = search.toLowerCase(); diff --git a/apps/mesh/src/web/components/thread/github/branch-picker.tsx b/apps/mesh/src/web/components/thread/github/branch-picker.tsx index 74ffc5f4fa..5c1b3b4dd6 100644 --- a/apps/mesh/src/web/components/thread/github/branch-picker.tsx +++ b/apps/mesh/src/web/components/thread/github/branch-picker.tsx @@ -16,7 +16,6 @@ import { PopoverTrigger, } from "@deco/ui/components/popover.tsx"; import { GitBranch01 } from "@untitledui/icons"; -import { generateBranchName } from "@/shared/branch-name"; import { useBranches } from "./use-branches"; interface Props { @@ -80,17 +79,7 @@ export function BranchPicker({ align="start" > -
- - -
+ {isError && (
diff --git a/apps/mesh/src/web/components/thread/github/changes-tab.tsx b/apps/mesh/src/web/components/thread/github/changes-tab.tsx index d30f8a0106..57a1389292 100644 --- a/apps/mesh/src/web/components/thread/github/changes-tab.tsx +++ b/apps/mesh/src/web/components/thread/github/changes-tab.tsx @@ -25,16 +25,12 @@ export function ChangesTab({ pr, connectionId, owner, repo }: Props) { }); if (filesQuery.isLoading) { - return ( -
Loading files…
- ); + return
Loading files…
; } if (filesQuery.isError) { return ( -
- Couldn't load file list. -
+
Couldn't load file list.
); } @@ -42,13 +38,13 @@ export function ChangesTab({ pr, connectionId, owner, repo }: Props) { if (files.length === 0) { return ( -
No files changed.
+
No files changed.
); } return ( -
-
+
+
{files.length} file{files.length === 1 ? "" : "s"} changed ·{" "} Loading checks…
- ); + return
Loading checks…
; } if (checksQuery.isError) { return ( -
- Couldn't load check runs. -
+
Couldn't load check runs.
); } @@ -57,14 +53,14 @@ export function ChecksTab({ pr, connectionId, owner, repo }: Props) { if (checks.length === 0) { return ( -
+
No check runs on the PR head commit.
); } return ( -