From 517506a2600ee61dba088b483b603b70715b5023 Mon Sep 17 00:00:00 2001 From: jennyf19 <19942418+jennyf19@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:49:35 -0700 Subject: [PATCH 1/7] feat(the-workshop): sync signals-dashboard to the-workshop@bc708fe Brings the awesome-copilot copy of the signals-dashboard (Cairn) extension to parity with jennyf19/the-workshop@bc708fe. The desk cards' path button becomes an open button: clicking it launches a Copilot CLI in that desk's folder, oriented to read the journal and continue, mirroring the workshop app's console launcher. This keeps every desk inside the one workshop repo instead of spinning off a separate checkout. The launcher prefers a wrapper agent CLI when one is on PATH (env-overridable via WORKSHOP_DESK_AGENT), and falls back to vanilla 'copilot' otherwise, so it works out of the box for everyone. New open_desk canvas action; /api/open now returns whether a terminal launched. plugin.json and assets/preview.png are unchanged. Provenance: jennyf19/the-workshop@bc708fe90503329605619688e7b2b3832de8c01b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62 --- extensions/signals-dashboard/extension.mjs | 140 +++++++++++++++++++-- plugins/the-workshop/README.md | 3 + 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs index 65d043a0d..b47489196 100644 --- a/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extension.mjs @@ -4,8 +4,10 @@ // Supports stashing desks (48hr hold) and restoring them. import { createServer } from "node:http"; +import { existsSync } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, delimiter } from "node:path"; +import { spawn } from "node:child_process"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; const servers = new Map(); @@ -31,6 +33,101 @@ 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. We additionally +// reject a path containing a double-quote before quoting it onto a command line +// (a real Windows path cannot contain one), mirroring ConsoleLauncher.SafeDir, +// so a planted workshop path can never break out of the -d "..." argument. +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. A short timer guards the rare case neither fires. +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)); + setTimeout(() => done(true, child), 600); + } 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. +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; + try { + if (existsSync(join(dir, command))) return true; + for (const ext of exts) if (existsSync(join(dir, command + ext))) return true; + } catch {} + } + } 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(); + const useAgency = pref === "copilot" ? false : isOnPath("agency"); + return useAgency ? ["agency", "copilot"] : ["copilot", "--name", deskName]; +} + +async function launchDeskConsole(deskPath, deskName) { + if (!deskPath || deskPath.includes('"')) return false; + const orient = deskOrientPrompt(deskName); + const run = [...deskAgentArgv(deskName), "-i", orient]; // agent + orientation + if (process.platform === "win32") { + // Windows Terminal is a GUI app, so it always surfaces its own visible + // window even though the extension host itself is windowless — opened in + // the desk folder, running the desk's agent oriented to the desk. + if (await trySpawn("wt.exe", ["-d", deskPath, ...run])) return true; + // Fallback when wt.exe is absent: a fresh console window via `start`, + // with the working directory set to the desk folder. + return await trySpawn("cmd.exe", ["/c", "start", "", ...run], { cwd: deskPath }); + } + if (process.platform === "darwin") { + // macOS: open Terminal.app in the desk folder. `open` can't inject the + // agent command, so it drops the user into the desk to run it. + return await trySpawn("open", ["-a", "Terminal", deskPath]); + } + // Linux/other: best-effort across common terminal emulators, cwd = desk. + for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) { + if (await trySpawn(term, [], { 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 @@ -403,7 +500,7 @@ function renderSignalCard(sig) { style="${openBtnStyle}" onmouseover="this.style.background='#1e3a5f'" onmouseout="this.style.background='${isEscalation ? '#7f1d1d' : 'transparent'}'" - title="Copy this desk's filesystem path to the clipboard">path`; + title="Open this desk as a Copilot CLI session in its folder">open`; let escalationBlock = ""; if (isEscalation && sig.escalationReason) { @@ -637,11 +734,14 @@ function renderDashboard(signals, stashed) { const data = await res.json(); if (data.ok) { const path = data.deskPath || name; - try { - await navigator.clipboard.writeText(path); + // Copy the path either way, so there's always a usable handle. + try { await navigator.clipboard.writeText(path); } catch {} + if (data.launched) { + showToast('opening ' + name + ' desk…', path); + } else { + // No terminal could be launched from here — fall back to the + // path so the user (or the TA) can open the desk themselves. showToast(name + ' · path copied', path); - } catch { - showToast(name, path); } } else { showToast(name + ' · not found', ''); @@ -741,8 +841,9 @@ async function startServer(instanceId, workshopDir) { try { const s = await stat(deskPath); if (s.isDirectory()) { + const launched = await launchDeskConsole(deskPath, deskName); res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, deskName, deskPath })); + res.end(JSON.stringify({ ok: true, deskName, deskPath, launched })); return; } } catch {} @@ -865,6 +966,31 @@ const session = await joinSession({ return { error: `Desk '${ctx.input.deskName}' not found` }; }, }, + { + name: "open_desk", + description: "Open a desk as an in-place Copilot CLI session: launches a terminal in the desk's folder (inside the workshop repo) running copilot, oriented to read the desk journal and continue. This is the Model A 'sit down at the desk' — no new worktree, no session spun off elsewhere. Returns the desk path and whether a terminal was launched.", + inputSchema: { + type: "object", + properties: { deskName: { type: "string", description: "Name of the desk to open" } }, + required: ["deskName"], + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Dashboard not open" }; + if (!isValidDeskName(ctx.input.deskName)) return { error: "Invalid desk name" }; + for (const subdir of ["desks", "classroom"]) { + const deskPath = join(entry.workshopDir, subdir, ctx.input.deskName); + try { + const s = await stat(deskPath); + if (s.isDirectory()) { + const launched = await launchDeskConsole(deskPath, ctx.input.deskName); + return { ok: true, deskName: ctx.input.deskName, deskPath, launched, workshopDir: entry.workshopDir }; + } + } catch {} + } + return { error: `Desk '${ctx.input.deskName}' not found` }; + }, + }, ], open: async (ctx) => { const workshopDir = ctx.input?.workshopDir || process.cwd(); diff --git a/plugins/the-workshop/README.md b/plugins/the-workshop/README.md index b038e0934..16c88a883 100644 --- a/plugins/the-workshop/README.md +++ b/plugins/the-workshop/README.md @@ -37,6 +37,9 @@ A **desk** isn't a sub-agent — it's a peer with a history. Sub-agents inherit The Workshop's live view is a **canvas extension** (🪨 Cairn) — `signals-dashboard` — that shows the pulse of every desk (score bars, patterns, escalations), auto-refreshing in the GitHub Copilot app. +Each desk card also has an **open** button that launches a Copilot CLI right in +that desk's folder, so you can sit down at a desk straight from the board. + It ships as a separate extension. Install it alongside the plugin to get the live canvas: ``` From d4656d988d80fe7c2ef9aab641cd7593a0716fb8 Mon Sep 17 00:00:00 2001 From: jennyf19 <19942418+jennyf19@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:09:46 -0700 Subject: [PATCH 2/7] fix(the-workshop): harden signals-dashboard desk launch Addresses the Copilot review on this PR: - deskAgentArgv: an explicit WORKSHOP_DESK_AGENT override is now authoritative. =agency insists on the wrapper even when it isn't on PATH; the PATH check is only used for automatic selection when unset. - launchDeskConsole: restrict the desk name to a conservative slug before it reaches any shell, and drop the double quotes from the orientation prompt, so the cmd.exe `start` fallback can no longer be turned into command execution by a desk directory named e.g. "review&calc". - macOS: drive Terminal via AppleScript to actually cd into the desk and exec the agent (POSIX single-quoted), instead of only opening Terminal and falsely reporting the CLI launched. - Linux: pass the agent command after each emulator's exec flag, so the desk comes up running its agent instead of a bare shell. - Client openDesk: track whether the clipboard write succeeded and stop claiming "path copied" when the browser blocked it. Synced from the-workshop@8e22ce84b2b28d9c2205730b1cd52db8586a0416 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62 --- extensions/signals-dashboard/extension.mjs | 92 +++++++++++++++++----- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs index b47489196..0fed4a7f9 100644 --- a/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extension.mjs @@ -45,7 +45,7 @@ function isValidDeskName(name) { // (a real Windows path cannot contain one), mirroring ConsoleLauncher.SafeDir, // so a planted workshop path can never break out of the -d "..." argument. function deskOrientPrompt(deskName) { - return `You are sitting down at the "${deskName}" desk in this workshop. ` + + 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.`; } @@ -99,31 +99,78 @@ function isOnPath(command) { // force vanilla, or =agency to insist on the wrapper. function deskAgentArgv(deskName) { const pref = (process.env.WORKSHOP_DESK_AGENT || "").trim().toLowerCase(); - const useAgency = pref === "copilot" ? false : isOnPath("agency"); + // 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 and double quotes. +function osaStringLiteral(s) { + return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} + async function launchDeskConsole(deskPath, deskName) { + // deskPath can't contain a double quote (a real Windows path never does and + // it would break out of a quoted argument). deskName must be a plain slug so + // it is safe on every command line and shell below. if (!deskPath || deskPath.includes('"')) return false; - const orient = deskOrientPrompt(deskName); - const run = [...deskAgentArgv(deskName), "-i", orient]; // agent + orientation + if (!isSafeDeskNameForLaunch(deskName)) return false; + const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; if (process.platform === "win32") { // Windows Terminal is a GUI app, so it always surfaces its own visible - // window even though the extension host itself is windowless — opened in - // the desk folder, running the desk's agent oriented to the desk. + // window even though the extension host is windowless. It is spawned via + // argv (no shell), so the contents of run are passed literally. if (await trySpawn("wt.exe", ["-d", deskPath, ...run])) return true; - // Fallback when wt.exe is absent: a fresh console window via `start`, - // with the working directory set to the desk folder. + // Fallback when wt.exe is absent: a fresh console window via `start`. + // `start` re-parses its tail through cmd, so this path is only safe + // because deskName is a slug and the orientation prompt carries no shell + // metacharacters or quotes; nothing untrusted reaches the parser. return await trySpawn("cmd.exe", ["/c", "start", "", ...run], { cwd: deskPath }); } if (process.platform === "darwin") { - // macOS: open Terminal.app in the desk folder. `open` can't inject the - // agent command, so it drops the user into the desk to run it. - return await trySpawn("open", ["-a", "Terminal", deskPath]); + // 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, cwd = desk. - for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) { - if (await trySpawn(term, [], { cwd: deskPath })) return true; + // 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; } @@ -734,14 +781,21 @@ function renderDashboard(signals, stashed) { const data = await res.json(); if (data.ok) { const path = data.deskPath || name; - // Copy the path either way, so there's always a usable handle. - try { await navigator.clipboard.writeText(path); } catch {} + // Copy the path either way so there's always a usable handle, but + // remember whether it actually succeeded so we never claim a copy + // that the browser blocked. + let copied = false; + try { await navigator.clipboard.writeText(path); copied = true; } catch {} if (data.launched) { showToast('opening ' + name + ' desk…', path); - } else { - // No terminal could be launched from here — fall back to the - // path so the user (or the TA) can open the desk themselves. + } else if (copied) { + // No terminal could be launched from here, so the copied path + // is the fallback the user (or the TA) opens the desk with. showToast(name + ' · path copied', path); + } else { + // Neither launch nor clipboard worked; the toast still shows + // the path below so it stays usable. + showToast(name + ' · copy this path', path); } } else { showToast(name + ' · not found', ''); From 055dda167292238892d34ac3d012aefbc72479a9 Mon Sep 17 00:00:00 2001 From: jennyf19 <19942418+jennyf19@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:28:43 -0700 Subject: [PATCH 3/7] signals-dashboard: harden desk launch (round 2 review) Synced from the-workshop@8106dd4. Addresses the second GHCP review pass on #2363: - isOnPath now requires a runnable file (X_OK / PATHEXT), not just existsSync. - launchDeskConsole adds a realpath-containment check (symlink escape) and takes workshopDir. - Dashboard copies the path only on the non-launch fallback. - Mutating /api/* routes now require the canonical loopback Host plus a per-server capability token (DNS-rebinding defense), mirroring connector-namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62 --- extensions/signals-dashboard/extension.mjs | 130 ++++++++++++++++----- 1 file changed, 99 insertions(+), 31 deletions(-) diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs index 0fed4a7f9..962b6d6b7 100644 --- a/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extension.mjs @@ -4,10 +4,11 @@ // Supports stashing desks (48hr hold) and restoring them. import { createServer } from "node:http"; -import { existsSync } from "node:fs"; +import { existsSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; -import { join, delimiter } 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(); @@ -74,6 +75,18 @@ function trySpawn(cmd, args, opts = {}) { // 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); @@ -82,10 +95,13 @@ function isOnPath(command) { : []; for (const dir of dirs) { if (!dir) continue; - try { - if (existsSync(join(dir, command))) return true; - for (const ext of exts) if (existsSync(join(dir, command + ext))) return true; - } catch {} + // 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; @@ -129,12 +145,28 @@ function osaStringLiteral(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } -async function launchDeskConsole(deskPath, deskName) { +// 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); + return t === r || t.startsWith(r + sep); + } catch { return false; } +} + +async function launchDeskConsole(deskPath, deskName, workshopDir) { // deskPath can't contain a double quote (a real Windows path never does and // it would break out of a quoted argument). deskName must be a plain slug so - // it is safe on every command line and shell below. + // 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). if (!deskPath || deskPath.includes('"')) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; + if (!isInsideRoot(workshopDir, deskPath)) return false; const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; if (process.platform === "win32") { // Windows Terminal is a GUI app, so it always surfaces its own visible @@ -219,6 +251,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) { @@ -675,7 +725,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 @@ -747,12 +797,17 @@ function renderDashboard(signals, stashed) {