diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs index 65d043a0..f45b6431 100644 --- a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs @@ -4,8 +4,11 @@ // Supports stashing desks (48hr hold) and restoring them. import { createServer } from "node:http"; +import { statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, delimiter, sep } from "node:path"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; const servers = new Map(); @@ -31,6 +34,198 @@ function isValidDeskName(name) { name !== "." && name !== ".."; } +// Launch a desk as an in-place Copilot CLI session — the canvas counterpart to +// WorkshopRoom's ConsoleLauncher. A desk is a seat that independent sessions +// pick up over time, so "open" starts a fresh copilot in the desk's own folder, +// oriented to read the journal and continue. This keeps every desk inside the +// one workshop repo (coordinated through journals + .signals + Cairn) instead +// of spinning off an isolated worktree elsewhere on disk. +// +// deskPath has already been confirmed to exist by the caller. Before launching +// we re-resolve it with realpath and require it to stay inside the workshop root +// (isInsideRoot), which defeats a planted desks/foo -> /outside symlink; the +// path is then only ever passed as a spawn cwd, an argv element, or a +// single-quoted literal inside the macOS Terminal command — never concatenated +// raw onto a command line — so no character filtering of the path is required. +function deskOrientPrompt(deskName) { + return `You are sitting down at the ${deskName} desk in this workshop. ` + + `Read journal.md in this folder first to pick up where the last session ` + + `left off, then continue the desk's work. Write your journal before you stop.`; +} + +// Spawn detached and resolve true only once the OS confirms the process +// started ('spawn'), false on failure ('error', e.g. the binary is missing) so +// the caller can fall back. Node guarantees exactly one of those events fires +// for a spawn attempt, so we resolve solely from them — no timeout that could +// report an unconfirmed launch as success and skip the clipboard fallback. +function trySpawn(cmd, args, opts = {}) { + return new Promise((resolve) => { + let settled = false; + const done = (v, child) => { + if (settled) return; + settled = true; + if (v && child) { try { child.unref(); } catch {} } + resolve(v); + }; + try { + const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...opts }); + child.on("error", () => done(false)); + child.on("spawn", () => done(true, child)); + } catch { resolve(false); } + }); +} + +// Resolve an executable on PATH (honoring PATHEXT on Windows), mirroring +// WorkshopRoom's AgentClis.IsOnPath. Used to prefer Agency when the machine has +// it installed, falling back to vanilla Copilot. +// A PATH hit only counts if it resolves to a real, runnable file. existsSync +// alone would treat a directory or a non-executable file named `agency` as a +// match, so auto-detection would pick the wrapper and the terminal would then +// fail to run it with no fallback. +function isExecutableFile(p) { + try { + if (!statSync(p).isFile()) return false; + if (process.platform !== "win32") accessSync(p, fsConstants.X_OK); + return true; + } catch { return false; } +} + +function isOnPath(command) { + try { + const dirs = (process.env.PATH || "").split(delimiter); + const exts = process.platform === "win32" + ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean) + : []; + for (const dir of dirs) { + if (!dir) continue; + // On Windows only a PATHEXT match is runnable; on POSIX check the bare + // name, and isExecutableFile confirms the execute bit either way. + if (exts.length) { + for (const ext of exts) if (isExecutableFile(join(dir, command + ext))) return true; + } else if (isExecutableFile(join(dir, command))) { + return true; + } + } + } catch {} + return false; +} + +// The agent argv a desk opens with. Default: prefer Agency (the internal +// wrapper around Copilot) when it's installed, so a desk comes up with its +// MCPs/plugin already configured instead of bare GHCP; otherwise vanilla +// Copilot. Agency can't take Copilot's --name (it clashes with Agency's own +// --resume), matching AgentClis. Override with WORKSHOP_DESK_AGENT=copilot to +// force vanilla, or =agency to insist on the wrapper. +function deskAgentArgv(deskName) { + const pref = (process.env.WORKSHOP_DESK_AGENT || "").trim().toLowerCase(); + // An explicit override is authoritative: =agency insists on the wrapper even + // when it isn't detected on PATH, and =copilot forces vanilla. Only when the + // override is unset do we auto-detect and prefer Agency if it's installed. + const useAgency = pref === "agency" ? true + : pref === "copilot" ? false + : isOnPath("agency"); + return useAgency ? ["agency", "copilot"] : ["copilot", "--name", deskName]; +} + +// A desk name flows onto a command line, and on the no-wt Windows fallback +// through cmd.exe. isValidDeskName still allows shell metacharacters such as +// & | > % ^, so the launcher additionally requires a conservative slug before +// any shell can see the name; anything else refuses to launch and the caller +// falls back to copying the path. Combined with the quote-free orientation +// prompt, no untrusted text ever reaches a shell parser. +function isSafeDeskNameForLaunch(name) { + return isValidDeskName(name) && /^[A-Za-z0-9._-]+$/.test(name); +} + +// POSIX single-quote a value for the macOS `do script` command line, escaping +// any embedded single quotes. +function shSingleQuote(s) { + return "'" + String(s).replace(/'/g, "'\\''") + "'"; +} + +// AppleScript string literal: escape backslashes, double quotes, and line breaks +// (a raw CR/LF in a path would otherwise terminate the literal and fail to +// compile, while osascript still spawns and trySpawn would report success). +function osaStringLiteral(s) { + return '"' + String(s) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + '"'; +} + +// Resolve symlinks on both sides and confirm the target is the workshop root +// itself or lives beneath it. The callers locate a desk with stat(), which +// follows symlinks, so a committed desks/foo -> /outside symlink would otherwise +// launch the agent with an external working directory, breaking the inside-repo +// guarantee. +function isInsideRoot(root, target) { + try { + const r = realpathSync(root); + const t = realpathSync(target); + if (t === r) return true; + // A filesystem root ("/" or "C:\") already ends with the separator, so + // don't append a second one or every desk below it would fail the prefix + // test and opening would always fall back. + const prefix = r.endsWith(sep) ? r : r + sep; + return t.startsWith(prefix); + } catch { return false; } +} + +async function launchDeskConsole(deskPath, deskName, workshopDir) { + // deskName must be a plain slug so it is safe on every command line and shell + // below, and the resolved desk must still live inside the workshop root + // (which defeats a symlinked desk that escapes the repo). deskPath itself is + // only ever passed as an argv element / spawn cwd (Windows via -d plus cwd, + // Linux via cwd) or as a single-quoted literal inside the macOS Terminal + // command, so an empty-path guard is all that is needed — a quote in the path + // can't break out of any of those. + if (!deskPath) return false; + if (!isSafeDeskNameForLaunch(deskName)) return false; + if (!isInsideRoot(workshopDir, deskPath)) return false; + const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; + if (process.platform === "win32") { + // Run the agent through cmd.exe (/k) so PATHEXT is applied: globally + // installed CLIs like `copilot`/`agency` are usually .cmd shims that + // Windows Terminal or a bare CreateProcess would fail to launch (they + // expect a literal executable, not a PATHEXT name). Windows Terminal is a + // GUI app, so it still surfaces its own window from the windowless host. + // Each element of run is its own argv token — deskName is a slug and the + // orientation prompt has no cmd metacharacters — and the desk path is + // passed via -d/cwd, so nothing untrusted is reparsed by a shell. + if (await trySpawn("wt.exe", ["-d", deskPath, "cmd", "/k", ...run])) return true; + // Fallback when wt.exe is absent: a fresh console window via `start`, + // still through cmd /k for the same PATHEXT resolution. + return await trySpawn("cmd.exe", ["/c", "start", "", "cmd", "/k", ...run], { cwd: deskPath }); + } + if (process.platform === "darwin") { + // macOS: `open` can't inject a command, so drive Terminal via AppleScript + // to cd into the desk and exec the agent. Each argv element is POSIX + // single-quoted so the shell can't reinterpret it, and osascript itself + // is spawned via argv (no shell). + const line = "cd " + shSingleQuote(deskPath) + " && exec " + + run.map(shSingleQuote).join(" "); + const script = 'tell application "Terminal"\n' + + " activate\n" + + " do script " + osaStringLiteral(line) + "\n" + + "end tell"; + return await trySpawn("osascript", ["-e", script]); + } + // Linux/other: best-effort across common terminal emulators. Each is spawned + // via argv (no shell) with the agent command after the emulator's exec flag, + // so the desk actually comes up running its agent instead of a bare shell. + const linuxTerms = [ + ["x-terminal-emulator", ["-e", ...run]], + ["gnome-terminal", ["--", ...run]], + ["konsole", ["-e", ...run]], + ["xterm", ["-e", ...run]], + ]; + for (const [term, args] of linuxTerms) { + if (await trySpawn(term, args, { cwd: deskPath })) return true; + } + return false; +} + // Signal JSON is agent-produced and unvalidated. Coerce numeric fields before // they reach the renderer so a nonnumeric value cannot inject markup or break // layout. toScore clamps self-assessment/quality scores to 0..max; toCount @@ -75,6 +270,24 @@ function isCrossSiteRequest(req) { return site === "cross-site" || site === "same-site"; } +// Pin the Host header to the exact loopback authority we bound. A DNS-rebinding +// page reaches us under its own hostname (Host: attacker.example:), so an +// exact match against 127.0.0.1: refuses those requests before any state +// change — Origin/Host equality alone doesn't, since the attacker controls both. +function isCanonicalHost(req, canonicalHost) { + return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase(); +} + +// Capability check for the per-server token minted at startup and embedded in +// the page we serve. Only the loopback document we rendered knows it, so a blind +// cross-origin/rebinding caller can't forge a mutating request even if it +// reached the socket. +function hasCapabilityToken(req, token) { + const header = req.headers["x-workshop-token"]; + const provided = Array.isArray(header) ? header[0] : header; + return typeof provided === "string" && provided.length > 0 && provided === token; +} + // --- Stash management --- async function readStash(workshopDir) { @@ -399,11 +612,11 @@ function renderSignalCard(sig) { const openBtnStyle = isEscalation ? "background:#7f1d1d;border:1px solid #dc2626;color:#fca5a5;padding:2px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;transition:all .15s;" : "background:none;border:1px solid #1e3a5f;color:#7dd3fc;padding:2px 8px;border-radius:4px;font-size:11px;cursor:pointer;transition:all .15s;"; - const openBtn = noSignal ? "" : ``; + title="Open this desk as a Copilot CLI session in its folder">open`; let escalationBlock = ""; if (isEscalation && sig.escalationReason) { @@ -531,7 +744,7 @@ function renderStashedCard(entry) { `; } -function renderDashboard(signals, stashed) { +function renderDashboard(signals, stashed, capabilityToken) { const activeSignals = sortSignals(signals.filter(s => !stashed.some(e => e.name === s.deskName))); const cards = activeSignals.length > 0 @@ -603,12 +816,17 @@ function renderDashboard(signals, stashed) {