diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index bbf95631..ba86b835 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1146,8 +1146,8 @@ { "name": "signals-dashboard", "source": "extensions/signals-dashboard", - "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", - "version": "0.1.0" + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", + "version": "0.2.0" }, { "name": "site-studio", diff --git a/extensions/signals-dashboard/.github/plugin/plugin.json b/extensions/signals-dashboard/.github/plugin/plugin.json index dccc9634..5b3ad3ed 100644 --- a/extensions/signals-dashboard/.github/plugin/plugin.json +++ b/extensions/signals-dashboard/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "signals-dashboard", - "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", - "version": "0.1.0", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", + "version": "0.2.0", "author": { "name": "jennyf19", "url": "https://github.com/jennyf19" diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs index f45b6431..3309f0f5 100644 --- a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs @@ -6,13 +6,26 @@ 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, delimiter, sep } from "node:path"; +import { join, delimiter, isAbsolute, sep } from "node:path"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { + buildDeskAgentArgv, + isDeskProfile, + isSafeWindowsCmdShim, + isWindowsAppExecutionAlias, + normalizeDeskProfile, + parsePluginMcpNames, + quoteWindowsCmdArgument, +} from "./launch-profile.mjs"; const servers = new Map(); const STASH_TTL_MS = 48 * 60 * 60 * 1000; +const MCP_DISCOVERY_TTL_MS = 5 * 60 * 1000; +const MCP_DISCOVERY_MAX_BYTES = 1024 * 1024; +const DEFAULT_DESK_PROFILE = normalizeDeskProfile(process.env.WORKSHOP_DESK_PROFILE); +const mcpDiscoveryCache = new Map(); // Serialize stash read-modify-write per workshop. The UI fires stash/restore // POSTs without awaiting each other, so two overlapping mutations could both @@ -83,48 +96,217 @@ function trySpawn(cmd, args, opts = {}) { // 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; } + return probeExecutableFile(p).ok; } -function isOnPath(command) { +function probeExecutableFile(p) { + try { + if (!statSync(p).isFile()) return { ok: false, errorCode: null }; + if (process.platform !== "win32") accessSync(p, fsConstants.X_OK); + return { ok: true, errorCode: null }; + } catch (error) { + return { ok: false, errorCode: error?.code || null }; + } +} + +function resolveOnPath(command, { directOnly = false, excludedRoot = null } = {}) { 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; + for (const rawDir of dirs) { + const dir = rawDir.replace(/^"(.*)"$/, "$1"); + if (!dir || !isAbsolute(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; + for (const ext of exts) { + if (directOnly && ![".EXE", ".COM"].includes(ext.toUpperCase())) continue; + const candidate = join(dir, command + ext); + const probe = probeExecutableFile(candidate); + const appAlias = directOnly && probe.errorCode === "EACCES" && + isWindowsAppExecutionAlias(candidate, process.env.LOCALAPPDATA); + if (!appAlias && !probe.ok) continue; + const resolved = appAlias ? candidate : realpathSync(candidate); + if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue; + return resolved; + } + } else { + const candidate = join(dir, command); + if (!isExecutableFile(candidate)) continue; + const resolved = realpathSync(candidate); + if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue; + return resolved; } } } catch {} - return false; + return null; } -// 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) { +function resolveDeskAgent(workshopDir) { 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]; + const agencyCommand = resolveOnPath("agency", { excludedRoot: workshopDir }); + const copilotCommand = resolveOnPath("copilot", { excludedRoot: workshopDir }); + // Explicit overrides are authoritative and fail closed when unavailable. + if (pref === "agency") { + return agencyCommand + ? { useAgency: true, agencyCommand, copilotCommand } + : null; + } + if (pref === "copilot") { + return copilotCommand + ? { useAgency: false, agencyCommand, copilotCommand } + : null; + } + if (agencyCommand) return { useAgency: true, agencyCommand, copilotCommand }; + if (copilotCommand) return { useAgency: false, agencyCommand, copilotCommand }; + return null; +} + +function resolveSystem32Executable(name) { + if (process.platform !== "win32") return null; + const root = process.env.SystemRoot || process.env.WINDIR; + if (!root || !isAbsolute(root)) return null; + try { + const candidate = join(root, "System32", name); + return isExecutableFile(candidate) ? realpathSync(candidate) : null; + } catch { + return null; + } +} + +function terminateProcessTree(child) { + if (!child || child.exitCode !== null) return; + if (process.platform === "win32" && child.pid) { + const taskkill = resolveSystem32Executable("taskkill.exe"); + if (!taskkill) return; + try { + const killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); + killer.unref(); + } catch {} + return; + } + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { + try { child.kill("SIGKILL"); } catch {} + } +} + +// Capture the underlying Copilot plugin inventory directly. Agency repo mode +// separately suppresses its own default/config plugins, so discovery does not +// need a wrapper process that can leave inherited pipes or descendants behind. +function capturePluginMcpJson(workshopDir, agent) { + return new Promise((resolve) => { + const command = agent.copilotCommand; + if (!command) { + resolve(null); + return; + } + const args = ["plugins", "list", "--kind", "mcp", "--scope", "plugin", "--json"]; + const shim = process.platform === "win32" && /\.(cmd|bat)$/i.test(command); + if (shim && !isSafeWindowsCmdShim(command)) { + resolve(null); + return; + } + const spawnCommand = shim ? resolveSystem32Executable("cmd.exe") : command; + if (!spawnCommand) { + resolve(null); + return; + } + const spawnArgs = shim + ? ["/d", "/s", "/c", + `"${[command, ...args].map(quoteWindowsCmdArgument).join(" ")}"`] + : args; + + let settled = false; + let stdout = ""; + const done = (value, child, timer, terminate = false) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (terminate) { + try { child.stdout.destroy(); } catch {} + terminateProcessTree(child); + } + resolve(value); + }; + + let child; + try { + child = spawn(spawnCommand, spawnArgs, { + cwd: workshopDir, + detached: process.platform !== "win32", + windowsHide: true, + windowsVerbatimArguments: shim, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve(null); + return; + } + + const timer = setTimeout(() => done(null, child, timer, true), 30000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (settled) return; + stdout += chunk; + if (Buffer.byteLength(stdout, "utf8") > MCP_DISCOVERY_MAX_BYTES) { + done(null, child, timer, true); + return; + } + if (parsePluginMcpNames(stdout) !== null) done(stdout, child, timer, true); + }); + child.on("error", () => done(null, child, timer)); + child.on("close", (code) => done(code === 0 ? stdout : null, child, timer)); + }); +} + +async function discoverPluginMcpNames(workshopDir, agent) { + const cacheKey = `${workshopDir}\0${agent.copilotCommand || "missing"}`; + const cached = mcpDiscoveryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const output = await capturePluginMcpJson(workshopDir, agent); + const names = output === null ? null : parsePluginMcpNames(output); + const value = names === null + ? { ok: false, names: [] } + : { ok: true, names }; + mcpDiscoveryCache.set(cacheKey, { + expiresAt: Date.now() + MCP_DISCOVERY_TTL_MS, + value, + }); + return value; +} + +// Preserve the existing Agency-aware launch and layer the repo profile on top. +// Repo mode suppresses ambient plugin MCPs; connected mode keeps today's tool +// surface. Plugin discovery fails open, while Agency repo mode still suppresses +// Agency's own default MCPs. +async function deskAgentArgv(deskName, workshopDir, profile) { + const resolved = resolveDeskAgent(workshopDir); + if (!resolved) return null; + const { useAgency, agencyCommand, copilotCommand } = resolved; + if (useAgency && !agencyCommand) return null; + if (!useAgency && !copilotCommand) return null; + const discovery = profile === "repo" + ? await discoverPluginMcpNames(workshopDir, resolved) + : { ok: true, names: [] }; + return buildDeskAgentArgv({ + deskName, + workshopDir, + useAgency, + agencyCommand, + copilotCommand, + profile, + pluginMcpNames: discovery.names, + discoverySucceeded: discovery.ok, + }); } // A desk name flows onto a command line, and on the no-wt Windows fallback @@ -172,7 +354,7 @@ function isInsideRoot(root, target) { } catch { return false; } } -async function launchDeskConsole(deskPath, deskName, workshopDir) { +async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFAULT_DESK_PROFILE) { // 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 @@ -183,22 +365,30 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { if (!deskPath) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; if (!isInsideRoot(workshopDir, deskPath)) return false; - const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; + const agent = await deskAgentArgv(deskName, workshopDir, profile); + if (!agent) return false; + const run = [...agent, "-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; + const wt = resolveOnPath("wt", { directOnly: true, excludedRoot: workshopDir }); + const cmd = resolveSystem32Executable("cmd.exe"); + const direct = /\.(exe|com)$/i.test(run[0]); + if (direct && wt && await trySpawn(wt, ["-d", deskPath, ...run])) return true; + + // Older installs can expose .cmd/.bat shims. Only use cmd.exe when every + // argument is free of cmd metacharacters; otherwise fail closed and let + // the UI copy the desk path rather than reparse an unsafe workshop path. + const cmdSafe = run.every((arg) => !/[&|<>^%!()\r\n]/.test(arg)); + if (cmdSafe && wt && cmd && + await trySpawn(wt, ["-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 }); + // still through cmd /k only when the arguments are safe for reparsing. + return cmdSafe && cmd + ? await trySpawn(cmd, ["/c", "start", "", cmd, "/k", ...run], { cwd: deskPath }) + : false; } if (process.platform === "darwin") { + const osascript = "/usr/bin/osascript"; + if (!isExecutableFile(osascript)) return false; // 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 @@ -209,7 +399,7 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { " activate\n" + " do script " + osaStringLiteral(line) + "\n" + "end tell"; - return await trySpawn("osascript", ["-e", script]); + 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, @@ -221,7 +411,8 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { ["xterm", ["-e", ...run]], ]; for (const [term, args] of linuxTerms) { - if (await trySpawn(term, args, { cwd: deskPath })) return true; + const executable = resolveOnPath(term, { excludedRoot: workshopDir }); + if (executable && await trySpawn(executable, args, { cwd: deskPath })) return true; } return false; } @@ -612,11 +803,20 @@ 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 = ``; + title="Open this desk with the ${esc(DEFAULT_DESK_PROFILE)} tool profile">open`; + const connectedBtn = DEFAULT_DESK_PROFILE === "connected" ? "" : ` + `; let escalationBlock = ""; if (isEscalation && sig.escalationReason) { @@ -719,6 +919,7 @@ function renderSignalCard(sig) { ${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""} ${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""} ${openBtn} + ${connectedBtn} ${stashBtn} @@ -850,14 +1051,16 @@ function renderDashboard(signals, stashed, capabilityToken) { document.body.appendChild(toast); setTimeout(() => toast.remove(), 4000); } - async function openDesk(name) { - const res = await fetch('/api/open/' + encodeURIComponent(name), POST_OPTS); + async function openDesk(name, profile) { + const selectedProfile = profile || ${JSON.stringify(DEFAULT_DESK_PROFILE)}; + const res = await fetch('/api/open/' + encodeURIComponent(name) + + '?profile=' + encodeURIComponent(selectedProfile), POST_OPTS); const data = await res.json(); if (data.ok) { const path = data.deskPath || name; if (data.launched) { // A successful open shouldn't hijack the user's clipboard. - showToast('opening ' + name + ' desk…', path); + showToast('opening ' + name + ' desk (' + selectedProfile + ')…', path); } else { // No terminal launched from here, so copy the path as the // fallback handle, but only claim the copy when it actually @@ -876,9 +1079,10 @@ function renderDashboard(signals, stashed, capabilityToken) { const name = btn.getAttribute('data-desk'); if (!name) return; const act = btn.getAttribute('data-act'); + const profile = btn.getAttribute('data-profile'); if (act === 'stash') stashDesk(name); else if (act === 'restore') restoreDesk(name); - else if (act === 'open') openDesk(name); + else if (act === 'open') openDesk(name, profile); }); async function refresh() { try { @@ -894,15 +1098,22 @@ function renderDashboard(signals, stashed, capabilityToken) { const active = document.activeElement; let focusKey = null; if (active && active.matches && active.matches('button[data-act]')) { - focusKey = active.getAttribute('data-act') + '|' + active.getAttribute('data-desk'); + focusKey = JSON.stringify([ + active.getAttribute('data-act'), + active.getAttribute('data-desk'), + active.getAttribute('data-profile') || '', + ]); } content.innerHTML = newContent.innerHTML; if (focusKey) { - const bar = focusKey.indexOf('|'); - const act = focusKey.slice(0, bar); - const desk = focusKey.slice(bar + 1); + const [act, desk, profile] = JSON.parse(focusKey); const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk; - const target = content.querySelector('button[data-act="' + act + '"][data-desk="' + escDesk + '"]'); + const profileSelector = profile + ? '[data-profile="' + profile + '"]' + : ':not([data-profile])'; + const target = content.querySelector( + 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' + + profileSelector); if (target) target.focus(); } } @@ -971,19 +1182,26 @@ async function startServer(instanceId, workshopDir) { } if (req.method === "POST" && url.pathname.startsWith("/api/open/")) { const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]); + const profileInput = url.searchParams.get("profile") || DEFAULT_DESK_PROFILE; if (!isValidDeskName(deskName)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "Invalid desk name" })); return; } + if (!isDeskProfile(profileInput)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "Invalid desk profile" })); + return; + } + const profile = normalizeDeskProfile(profileInput); for (const subdir of ["desks", "classroom"]) { const deskPath = join(workshopDir, subdir, deskName); try { const s = await stat(deskPath); if (s.isDirectory()) { - const launched = await launchDeskConsole(deskPath, deskName, workshopDir); + const launched = await launchDeskConsole(deskPath, deskName, workshopDir, profile); res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, deskName, deskPath, launched })); + res.end(JSON.stringify({ ok: true, deskName, deskPath, launched, profile })); return; } } catch {} @@ -1109,23 +1327,41 @@ const session = await joinSession({ }, { 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.", + description: "Open a desk as an in-place Copilot CLI session. Repo profile suppresses ambient plugin MCPs; connected keeps every configured tool. Returns the desk path, profile, and whether a terminal was launched.", inputSchema: { type: "object", - properties: { deskName: { type: "string", description: "Name of the desk to open" } }, + properties: { + deskName: { type: "string", description: "Name of the desk to open" }, + profile: { + type: "string", + enum: ["repo", "connected"], + description: `Tool profile. Defaults to ${DEFAULT_DESK_PROFILE}.`, + }, + }, 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" }; + const profileInput = ctx.input.profile || DEFAULT_DESK_PROFILE; + if (!isDeskProfile(profileInput)) return { error: "Invalid desk profile" }; + const profile = normalizeDeskProfile(profileInput); 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, entry.workshopDir); - return { ok: true, deskName: ctx.input.deskName, deskPath, launched, workshopDir: entry.workshopDir }; + const launched = await launchDeskConsole( + deskPath, ctx.input.deskName, entry.workshopDir, profile); + return { + ok: true, + deskName: ctx.input.deskName, + deskPath, + launched, + workshopDir: entry.workshopDir, + profile, + }; } } catch {} } diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs new file mode 100644 index 00000000..4f06c6f2 --- /dev/null +++ b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs @@ -0,0 +1,88 @@ +const PROFILES = new Set(["repo", "connected"]); +const SAFE_MCP_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export function isDeskProfile(value) { + return typeof value === "string" && PROFILES.has(value.toLowerCase()); +} + +export function normalizeDeskProfile(value, fallback = "repo") { + return isDeskProfile(value) ? value.toLowerCase() : fallback; +} + +export function isWindowsAppExecutionAlias(candidate, localAppData) { + if (typeof candidate !== "string" || typeof localAppData !== "string") return false; + const normalized = candidate.replaceAll("/", "\\").toLowerCase(); + const root = `${localAppData.replaceAll("/", "\\").replace(/\\+$/, "")}` + + "\\microsoft\\windowsapps\\"; + return normalized.startsWith(root.toLowerCase()) && normalized.endsWith(".exe"); +} + +export function quoteWindowsCmdArgument(value) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +export function isSafeWindowsCmdShim(value) { + return typeof value === "string" && !/[%\r\n]/.test(value); +} + +export function parsePluginMcpNames(text) { + let parsed; + try { parsed = JSON.parse(text); } + catch { + // Agency may prefix its pass-through command with human-readable startup + // lines. Its underlying Copilot JSON is the final object in stdout. + let start = text.lastIndexOf("{"); + while (start >= 0) { + try { + parsed = JSON.parse(text.slice(start)); + break; + } catch { + start = text.lastIndexOf("{", start - 1); + } + } + if (start < 0) return null; + } + + if (!Array.isArray(parsed?.plugins)) return null; + + const names = []; + const seen = new Set(); + for (const entry of parsed.plugins) { + if (entry?.kind !== "mcp" || entry.enabled === false) continue; + if (entry.scope !== "plugin" && entry.source !== "plugin") continue; + if (typeof entry.name !== "string" || !SAFE_MCP_NAME.test(entry.name)) continue; + if (!seen.has(entry.name)) { + seen.add(entry.name); + names.push(entry.name); + } + } + return names; +} + +export function buildDeskAgentArgv({ + deskName, + workshopDir, + useAgency, + agencyCommand = "agency", + copilotCommand = "copilot", + profile = "repo", + pluginMcpNames = [], + discoverySucceeded = true, +}) { + const argv = useAgency + ? [agencyCommand, "copilot"] + : [copilotCommand, "--name", deskName]; + + if (profile === "repo") { + if (useAgency) argv.push("--no-default-mcps"); + } + + if (profile === "repo" && discoverySucceeded) { + for (const name of pluginMcpNames) { + if (SAFE_MCP_NAME.test(name)) argv.push("--disable-mcp-server", name); + } + } + + argv.push("--add-dir", workshopDir); + return argv; +} diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs new file mode 100644 index 00000000..bde1f730 --- /dev/null +++ b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildDeskAgentArgv, + isDeskProfile, + isSafeWindowsCmdShim, + isWindowsAppExecutionAlias, + normalizeDeskProfile, + parsePluginMcpNames, + quoteWindowsCmdArgument, +} from "./launch-profile.mjs"; + +test("normalizes supported profiles and defaults unknown values to repo", () => { + assert.equal(isDeskProfile("repo"), true); + assert.equal(isDeskProfile("CONNECTED"), true); + assert.equal(isDeskProfile("other"), false); + assert.equal(normalizeDeskProfile("CONNECTED"), "connected"); + assert.equal(normalizeDeskProfile("other"), "repo"); +}); + +test("recognizes Windows App Execution Alias paths without trusting repository executables", () => { + assert.equal(isWindowsAppExecutionAlias( + "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe", + "C:\\Users\\person\\AppData\\Local"), true); + assert.equal(isWindowsAppExecutionAlias( + "C:\\repo\\wt.exe", + "C:\\Users\\person\\AppData\\Local"), false); + assert.equal(isWindowsAppExecutionAlias( + "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.cmd", + "C:\\Users\\person\\AppData\\Local"), false); +}); + +test("quotes trusted cmd shim arguments and rejects percent-bearing paths", () => { + assert.equal( + quoteWindowsCmdArgument("C:\\Program Files\\Agency\\agency.cmd"), + "\"C:\\Program Files\\Agency\\agency.cmd\""); + assert.equal(quoteWindowsCmdArgument("--scope"), "\"--scope\""); + assert.equal(isSafeWindowsCmdShim("C:\\Program Files\\Agency\\agency.cmd"), true); + assert.equal(isSafeWindowsCmdShim("C:\\Users\\%USERNAME%\\agency.cmd"), false); +}); + +test("executes a Windows cmd shim with safe quoting", { + skip: process.platform !== "win32", +}, () => { + const root = mkdtempSync(join(tmpdir(), "workshop-profile-")); + const shimDir = join(root, "Shim Name"); + mkdirSync(shimDir); + const shim = join(shimDir, "copilot.cmd"); + writeFileSync(shim, "@echo off\r\necho {\"plugins\":[]}\r\n"); + + const cmd = join(process.env.SystemRoot, "System32", "cmd.exe"); + const commandLine = `"${[shim, "plugins", "list"] + .map(quoteWindowsCmdArgument) + .join(" ")}"`; + const result = spawnSync(cmd, ["/d", "/s", "/c", commandLine], { + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: true, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\{"plugins":\[\]\}/); +}); + +test("extracts enabled plugin-scoped MCP names and rejects unsafe names", () => { + const names = parsePluginMcpNames(JSON.stringify({ + plugins: [ + { kind: "mcp", name: "teams", scope: "plugin", enabled: true }, + { kind: "mcp", name: "repo-mcp", source: "plugin", enabled: true }, + { kind: "mcp", name: "teams", scope: "plugin", enabled: true }, + { kind: "mcp", name: "disabled", scope: "plugin", enabled: false }, + { kind: "mcp", name: "workspace", scope: "repository", enabled: true }, + { kind: "skill", name: "not-an-mcp", scope: "plugin", enabled: true }, + { kind: "mcp", name: "bad;name", scope: "plugin", enabled: true }, + ], + })); + + assert.deepEqual(names, ["teams", "repo-mcp"]); + assert.deepEqual(parsePluginMcpNames(`Agency startup\n${JSON.stringify({ + plugins: [{ kind: "mcp", name: "ado", scope: "plugin", enabled: true }], + })}`), ["ado"]); + assert.equal(parsePluginMcpNames("not json"), null); + assert.equal(parsePluginMcpNames("{}"), null); +}); + +test("builds an Agency repo profile on top of the existing wrapper", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "C:\\workshop", + useAgency: true, + agencyCommand: "C:\\tools\\agency.exe", + profile: "repo", + pluginMcpNames: ["teams", "ado"], + }), [ + "C:\\tools\\agency.exe", "copilot", "--no-default-mcps", + "--disable-mcp-server", "teams", + "--disable-mcp-server", "ado", + "--add-dir", "C:\\workshop", + ]); +}); + +test("builds a plain Copilot repo profile without Agency-only flags", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: false, + copilotCommand: "/usr/local/bin/copilot", + profile: "repo", + pluginMcpNames: ["calendar"], + }), [ + "/usr/local/bin/copilot", "--name", "cost-desk", + "--disable-mcp-server", "calendar", + "--add-dir", "/workshop", + ]); +}); + +test("connected preserves tools while Agency discovery failure still removes defaults", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: true, + profile: "connected", + pluginMcpNames: ["teams"], + }), [ + "agency", "copilot", "--add-dir", "/workshop", + ]); + + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: true, + profile: "repo", + pluginMcpNames: [], + discoverySucceeded: false, + }), [ + "agency", "copilot", "--no-default-mcps", "--add-dir", "/workshop", + ]); +}); diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/package.json b/extensions/signals-dashboard/extensions/signals-dashboard/package.json index b1625c1a..6165e97c 100644 --- a/extensions/signals-dashboard/extensions/signals-dashboard/package.json +++ b/extensions/signals-dashboard/extensions/signals-dashboard/package.json @@ -1,12 +1,15 @@ { "name": "signals-dashboard", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "extension.mjs", + "scripts": { + "test": "node --test launch-profile.test.mjs" + }, "dependencies": { "@github/copilot-sdk": "latest" }, - "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", "keywords": [ "agent-signals", "dashboard", diff --git a/plugins/the-workshop/README.md b/plugins/the-workshop/README.md index 16c88a88..a0aecb3f 100644 --- a/plugins/the-workshop/README.md +++ b/plugins/the-workshop/README.md @@ -37,8 +37,19 @@ 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. +Each desk card has two launch choices: + +- **open** — the cost-aware repo profile. The desk keeps access to the Workshop + root for cross-desk journals and artifacts while ambient plugin MCPs are + suppressed. +- **connected** — the full configured tool surface for work that needs external + systems such as issue trackers, mail, or service APIs. + +Set `WORKSHOP_DESK_PROFILE=connected` to make the main **open** button use the +full tool surface; in that mode the separate **connected** button is omitted. + +Both launch a CLI in the desk's folder. If Agency is installed, Cairn keeps +using the existing Agency wrapper; otherwise it launches plain Copilot CLI. It ships as a separate extension. Install it alongside the plugin to get the live canvas: diff --git a/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json b/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json index 7bfd8dbc..7e4e6cd4 100644 --- a/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json +++ b/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "signals-dashboard", - "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", - "version": "0.1.0", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", + "version": "0.2.0", "author": { "name": "jennyf19", "url": "https://github.com/jennyf19" diff --git a/plugins/the-workshop/extensions/signals-dashboard/extension.mjs b/plugins/the-workshop/extensions/signals-dashboard/extension.mjs index f45b6431..3309f0f5 100644 --- a/plugins/the-workshop/extensions/signals-dashboard/extension.mjs +++ b/plugins/the-workshop/extensions/signals-dashboard/extension.mjs @@ -6,13 +6,26 @@ 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, delimiter, sep } from "node:path"; +import { join, delimiter, isAbsolute, sep } from "node:path"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { + buildDeskAgentArgv, + isDeskProfile, + isSafeWindowsCmdShim, + isWindowsAppExecutionAlias, + normalizeDeskProfile, + parsePluginMcpNames, + quoteWindowsCmdArgument, +} from "./launch-profile.mjs"; const servers = new Map(); const STASH_TTL_MS = 48 * 60 * 60 * 1000; +const MCP_DISCOVERY_TTL_MS = 5 * 60 * 1000; +const MCP_DISCOVERY_MAX_BYTES = 1024 * 1024; +const DEFAULT_DESK_PROFILE = normalizeDeskProfile(process.env.WORKSHOP_DESK_PROFILE); +const mcpDiscoveryCache = new Map(); // Serialize stash read-modify-write per workshop. The UI fires stash/restore // POSTs without awaiting each other, so two overlapping mutations could both @@ -83,48 +96,217 @@ function trySpawn(cmd, args, opts = {}) { // 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; } + return probeExecutableFile(p).ok; } -function isOnPath(command) { +function probeExecutableFile(p) { + try { + if (!statSync(p).isFile()) return { ok: false, errorCode: null }; + if (process.platform !== "win32") accessSync(p, fsConstants.X_OK); + return { ok: true, errorCode: null }; + } catch (error) { + return { ok: false, errorCode: error?.code || null }; + } +} + +function resolveOnPath(command, { directOnly = false, excludedRoot = null } = {}) { 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; + for (const rawDir of dirs) { + const dir = rawDir.replace(/^"(.*)"$/, "$1"); + if (!dir || !isAbsolute(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; + for (const ext of exts) { + if (directOnly && ![".EXE", ".COM"].includes(ext.toUpperCase())) continue; + const candidate = join(dir, command + ext); + const probe = probeExecutableFile(candidate); + const appAlias = directOnly && probe.errorCode === "EACCES" && + isWindowsAppExecutionAlias(candidate, process.env.LOCALAPPDATA); + if (!appAlias && !probe.ok) continue; + const resolved = appAlias ? candidate : realpathSync(candidate); + if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue; + return resolved; + } + } else { + const candidate = join(dir, command); + if (!isExecutableFile(candidate)) continue; + const resolved = realpathSync(candidate); + if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue; + return resolved; } } } catch {} - return false; + return null; } -// 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) { +function resolveDeskAgent(workshopDir) { 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]; + const agencyCommand = resolveOnPath("agency", { excludedRoot: workshopDir }); + const copilotCommand = resolveOnPath("copilot", { excludedRoot: workshopDir }); + // Explicit overrides are authoritative and fail closed when unavailable. + if (pref === "agency") { + return agencyCommand + ? { useAgency: true, agencyCommand, copilotCommand } + : null; + } + if (pref === "copilot") { + return copilotCommand + ? { useAgency: false, agencyCommand, copilotCommand } + : null; + } + if (agencyCommand) return { useAgency: true, agencyCommand, copilotCommand }; + if (copilotCommand) return { useAgency: false, agencyCommand, copilotCommand }; + return null; +} + +function resolveSystem32Executable(name) { + if (process.platform !== "win32") return null; + const root = process.env.SystemRoot || process.env.WINDIR; + if (!root || !isAbsolute(root)) return null; + try { + const candidate = join(root, "System32", name); + return isExecutableFile(candidate) ? realpathSync(candidate) : null; + } catch { + return null; + } +} + +function terminateProcessTree(child) { + if (!child || child.exitCode !== null) return; + if (process.platform === "win32" && child.pid) { + const taskkill = resolveSystem32Executable("taskkill.exe"); + if (!taskkill) return; + try { + const killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); + killer.unref(); + } catch {} + return; + } + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { + try { child.kill("SIGKILL"); } catch {} + } +} + +// Capture the underlying Copilot plugin inventory directly. Agency repo mode +// separately suppresses its own default/config plugins, so discovery does not +// need a wrapper process that can leave inherited pipes or descendants behind. +function capturePluginMcpJson(workshopDir, agent) { + return new Promise((resolve) => { + const command = agent.copilotCommand; + if (!command) { + resolve(null); + return; + } + const args = ["plugins", "list", "--kind", "mcp", "--scope", "plugin", "--json"]; + const shim = process.platform === "win32" && /\.(cmd|bat)$/i.test(command); + if (shim && !isSafeWindowsCmdShim(command)) { + resolve(null); + return; + } + const spawnCommand = shim ? resolveSystem32Executable("cmd.exe") : command; + if (!spawnCommand) { + resolve(null); + return; + } + const spawnArgs = shim + ? ["/d", "/s", "/c", + `"${[command, ...args].map(quoteWindowsCmdArgument).join(" ")}"`] + : args; + + let settled = false; + let stdout = ""; + const done = (value, child, timer, terminate = false) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (terminate) { + try { child.stdout.destroy(); } catch {} + terminateProcessTree(child); + } + resolve(value); + }; + + let child; + try { + child = spawn(spawnCommand, spawnArgs, { + cwd: workshopDir, + detached: process.platform !== "win32", + windowsHide: true, + windowsVerbatimArguments: shim, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve(null); + return; + } + + const timer = setTimeout(() => done(null, child, timer, true), 30000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (settled) return; + stdout += chunk; + if (Buffer.byteLength(stdout, "utf8") > MCP_DISCOVERY_MAX_BYTES) { + done(null, child, timer, true); + return; + } + if (parsePluginMcpNames(stdout) !== null) done(stdout, child, timer, true); + }); + child.on("error", () => done(null, child, timer)); + child.on("close", (code) => done(code === 0 ? stdout : null, child, timer)); + }); +} + +async function discoverPluginMcpNames(workshopDir, agent) { + const cacheKey = `${workshopDir}\0${agent.copilotCommand || "missing"}`; + const cached = mcpDiscoveryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const output = await capturePluginMcpJson(workshopDir, agent); + const names = output === null ? null : parsePluginMcpNames(output); + const value = names === null + ? { ok: false, names: [] } + : { ok: true, names }; + mcpDiscoveryCache.set(cacheKey, { + expiresAt: Date.now() + MCP_DISCOVERY_TTL_MS, + value, + }); + return value; +} + +// Preserve the existing Agency-aware launch and layer the repo profile on top. +// Repo mode suppresses ambient plugin MCPs; connected mode keeps today's tool +// surface. Plugin discovery fails open, while Agency repo mode still suppresses +// Agency's own default MCPs. +async function deskAgentArgv(deskName, workshopDir, profile) { + const resolved = resolveDeskAgent(workshopDir); + if (!resolved) return null; + const { useAgency, agencyCommand, copilotCommand } = resolved; + if (useAgency && !agencyCommand) return null; + if (!useAgency && !copilotCommand) return null; + const discovery = profile === "repo" + ? await discoverPluginMcpNames(workshopDir, resolved) + : { ok: true, names: [] }; + return buildDeskAgentArgv({ + deskName, + workshopDir, + useAgency, + agencyCommand, + copilotCommand, + profile, + pluginMcpNames: discovery.names, + discoverySucceeded: discovery.ok, + }); } // A desk name flows onto a command line, and on the no-wt Windows fallback @@ -172,7 +354,7 @@ function isInsideRoot(root, target) { } catch { return false; } } -async function launchDeskConsole(deskPath, deskName, workshopDir) { +async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFAULT_DESK_PROFILE) { // 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 @@ -183,22 +365,30 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { if (!deskPath) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; if (!isInsideRoot(workshopDir, deskPath)) return false; - const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; + const agent = await deskAgentArgv(deskName, workshopDir, profile); + if (!agent) return false; + const run = [...agent, "-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; + const wt = resolveOnPath("wt", { directOnly: true, excludedRoot: workshopDir }); + const cmd = resolveSystem32Executable("cmd.exe"); + const direct = /\.(exe|com)$/i.test(run[0]); + if (direct && wt && await trySpawn(wt, ["-d", deskPath, ...run])) return true; + + // Older installs can expose .cmd/.bat shims. Only use cmd.exe when every + // argument is free of cmd metacharacters; otherwise fail closed and let + // the UI copy the desk path rather than reparse an unsafe workshop path. + const cmdSafe = run.every((arg) => !/[&|<>^%!()\r\n]/.test(arg)); + if (cmdSafe && wt && cmd && + await trySpawn(wt, ["-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 }); + // still through cmd /k only when the arguments are safe for reparsing. + return cmdSafe && cmd + ? await trySpawn(cmd, ["/c", "start", "", cmd, "/k", ...run], { cwd: deskPath }) + : false; } if (process.platform === "darwin") { + const osascript = "/usr/bin/osascript"; + if (!isExecutableFile(osascript)) return false; // 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 @@ -209,7 +399,7 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { " activate\n" + " do script " + osaStringLiteral(line) + "\n" + "end tell"; - return await trySpawn("osascript", ["-e", script]); + 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, @@ -221,7 +411,8 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) { ["xterm", ["-e", ...run]], ]; for (const [term, args] of linuxTerms) { - if (await trySpawn(term, args, { cwd: deskPath })) return true; + const executable = resolveOnPath(term, { excludedRoot: workshopDir }); + if (executable && await trySpawn(executable, args, { cwd: deskPath })) return true; } return false; } @@ -612,11 +803,20 @@ 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 = ``; + title="Open this desk with the ${esc(DEFAULT_DESK_PROFILE)} tool profile">open`; + const connectedBtn = DEFAULT_DESK_PROFILE === "connected" ? "" : ` + `; let escalationBlock = ""; if (isEscalation && sig.escalationReason) { @@ -719,6 +919,7 @@ function renderSignalCard(sig) { ${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""} ${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""} ${openBtn} + ${connectedBtn} ${stashBtn} @@ -850,14 +1051,16 @@ function renderDashboard(signals, stashed, capabilityToken) { document.body.appendChild(toast); setTimeout(() => toast.remove(), 4000); } - async function openDesk(name) { - const res = await fetch('/api/open/' + encodeURIComponent(name), POST_OPTS); + async function openDesk(name, profile) { + const selectedProfile = profile || ${JSON.stringify(DEFAULT_DESK_PROFILE)}; + const res = await fetch('/api/open/' + encodeURIComponent(name) + + '?profile=' + encodeURIComponent(selectedProfile), POST_OPTS); const data = await res.json(); if (data.ok) { const path = data.deskPath || name; if (data.launched) { // A successful open shouldn't hijack the user's clipboard. - showToast('opening ' + name + ' desk…', path); + showToast('opening ' + name + ' desk (' + selectedProfile + ')…', path); } else { // No terminal launched from here, so copy the path as the // fallback handle, but only claim the copy when it actually @@ -876,9 +1079,10 @@ function renderDashboard(signals, stashed, capabilityToken) { const name = btn.getAttribute('data-desk'); if (!name) return; const act = btn.getAttribute('data-act'); + const profile = btn.getAttribute('data-profile'); if (act === 'stash') stashDesk(name); else if (act === 'restore') restoreDesk(name); - else if (act === 'open') openDesk(name); + else if (act === 'open') openDesk(name, profile); }); async function refresh() { try { @@ -894,15 +1098,22 @@ function renderDashboard(signals, stashed, capabilityToken) { const active = document.activeElement; let focusKey = null; if (active && active.matches && active.matches('button[data-act]')) { - focusKey = active.getAttribute('data-act') + '|' + active.getAttribute('data-desk'); + focusKey = JSON.stringify([ + active.getAttribute('data-act'), + active.getAttribute('data-desk'), + active.getAttribute('data-profile') || '', + ]); } content.innerHTML = newContent.innerHTML; if (focusKey) { - const bar = focusKey.indexOf('|'); - const act = focusKey.slice(0, bar); - const desk = focusKey.slice(bar + 1); + const [act, desk, profile] = JSON.parse(focusKey); const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk; - const target = content.querySelector('button[data-act="' + act + '"][data-desk="' + escDesk + '"]'); + const profileSelector = profile + ? '[data-profile="' + profile + '"]' + : ':not([data-profile])'; + const target = content.querySelector( + 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' + + profileSelector); if (target) target.focus(); } } @@ -971,19 +1182,26 @@ async function startServer(instanceId, workshopDir) { } if (req.method === "POST" && url.pathname.startsWith("/api/open/")) { const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]); + const profileInput = url.searchParams.get("profile") || DEFAULT_DESK_PROFILE; if (!isValidDeskName(deskName)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "Invalid desk name" })); return; } + if (!isDeskProfile(profileInput)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "Invalid desk profile" })); + return; + } + const profile = normalizeDeskProfile(profileInput); for (const subdir of ["desks", "classroom"]) { const deskPath = join(workshopDir, subdir, deskName); try { const s = await stat(deskPath); if (s.isDirectory()) { - const launched = await launchDeskConsole(deskPath, deskName, workshopDir); + const launched = await launchDeskConsole(deskPath, deskName, workshopDir, profile); res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, deskName, deskPath, launched })); + res.end(JSON.stringify({ ok: true, deskName, deskPath, launched, profile })); return; } } catch {} @@ -1109,23 +1327,41 @@ const session = await joinSession({ }, { 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.", + description: "Open a desk as an in-place Copilot CLI session. Repo profile suppresses ambient plugin MCPs; connected keeps every configured tool. Returns the desk path, profile, and whether a terminal was launched.", inputSchema: { type: "object", - properties: { deskName: { type: "string", description: "Name of the desk to open" } }, + properties: { + deskName: { type: "string", description: "Name of the desk to open" }, + profile: { + type: "string", + enum: ["repo", "connected"], + description: `Tool profile. Defaults to ${DEFAULT_DESK_PROFILE}.`, + }, + }, 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" }; + const profileInput = ctx.input.profile || DEFAULT_DESK_PROFILE; + if (!isDeskProfile(profileInput)) return { error: "Invalid desk profile" }; + const profile = normalizeDeskProfile(profileInput); 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, entry.workshopDir); - return { ok: true, deskName: ctx.input.deskName, deskPath, launched, workshopDir: entry.workshopDir }; + const launched = await launchDeskConsole( + deskPath, ctx.input.deskName, entry.workshopDir, profile); + return { + ok: true, + deskName: ctx.input.deskName, + deskPath, + launched, + workshopDir: entry.workshopDir, + profile, + }; } } catch {} } diff --git a/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs new file mode 100644 index 00000000..4f06c6f2 --- /dev/null +++ b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs @@ -0,0 +1,88 @@ +const PROFILES = new Set(["repo", "connected"]); +const SAFE_MCP_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export function isDeskProfile(value) { + return typeof value === "string" && PROFILES.has(value.toLowerCase()); +} + +export function normalizeDeskProfile(value, fallback = "repo") { + return isDeskProfile(value) ? value.toLowerCase() : fallback; +} + +export function isWindowsAppExecutionAlias(candidate, localAppData) { + if (typeof candidate !== "string" || typeof localAppData !== "string") return false; + const normalized = candidate.replaceAll("/", "\\").toLowerCase(); + const root = `${localAppData.replaceAll("/", "\\").replace(/\\+$/, "")}` + + "\\microsoft\\windowsapps\\"; + return normalized.startsWith(root.toLowerCase()) && normalized.endsWith(".exe"); +} + +export function quoteWindowsCmdArgument(value) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +export function isSafeWindowsCmdShim(value) { + return typeof value === "string" && !/[%\r\n]/.test(value); +} + +export function parsePluginMcpNames(text) { + let parsed; + try { parsed = JSON.parse(text); } + catch { + // Agency may prefix its pass-through command with human-readable startup + // lines. Its underlying Copilot JSON is the final object in stdout. + let start = text.lastIndexOf("{"); + while (start >= 0) { + try { + parsed = JSON.parse(text.slice(start)); + break; + } catch { + start = text.lastIndexOf("{", start - 1); + } + } + if (start < 0) return null; + } + + if (!Array.isArray(parsed?.plugins)) return null; + + const names = []; + const seen = new Set(); + for (const entry of parsed.plugins) { + if (entry?.kind !== "mcp" || entry.enabled === false) continue; + if (entry.scope !== "plugin" && entry.source !== "plugin") continue; + if (typeof entry.name !== "string" || !SAFE_MCP_NAME.test(entry.name)) continue; + if (!seen.has(entry.name)) { + seen.add(entry.name); + names.push(entry.name); + } + } + return names; +} + +export function buildDeskAgentArgv({ + deskName, + workshopDir, + useAgency, + agencyCommand = "agency", + copilotCommand = "copilot", + profile = "repo", + pluginMcpNames = [], + discoverySucceeded = true, +}) { + const argv = useAgency + ? [agencyCommand, "copilot"] + : [copilotCommand, "--name", deskName]; + + if (profile === "repo") { + if (useAgency) argv.push("--no-default-mcps"); + } + + if (profile === "repo" && discoverySucceeded) { + for (const name of pluginMcpNames) { + if (SAFE_MCP_NAME.test(name)) argv.push("--disable-mcp-server", name); + } + } + + argv.push("--add-dir", workshopDir); + return argv; +} diff --git a/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs new file mode 100644 index 00000000..bde1f730 --- /dev/null +++ b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildDeskAgentArgv, + isDeskProfile, + isSafeWindowsCmdShim, + isWindowsAppExecutionAlias, + normalizeDeskProfile, + parsePluginMcpNames, + quoteWindowsCmdArgument, +} from "./launch-profile.mjs"; + +test("normalizes supported profiles and defaults unknown values to repo", () => { + assert.equal(isDeskProfile("repo"), true); + assert.equal(isDeskProfile("CONNECTED"), true); + assert.equal(isDeskProfile("other"), false); + assert.equal(normalizeDeskProfile("CONNECTED"), "connected"); + assert.equal(normalizeDeskProfile("other"), "repo"); +}); + +test("recognizes Windows App Execution Alias paths without trusting repository executables", () => { + assert.equal(isWindowsAppExecutionAlias( + "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe", + "C:\\Users\\person\\AppData\\Local"), true); + assert.equal(isWindowsAppExecutionAlias( + "C:\\repo\\wt.exe", + "C:\\Users\\person\\AppData\\Local"), false); + assert.equal(isWindowsAppExecutionAlias( + "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.cmd", + "C:\\Users\\person\\AppData\\Local"), false); +}); + +test("quotes trusted cmd shim arguments and rejects percent-bearing paths", () => { + assert.equal( + quoteWindowsCmdArgument("C:\\Program Files\\Agency\\agency.cmd"), + "\"C:\\Program Files\\Agency\\agency.cmd\""); + assert.equal(quoteWindowsCmdArgument("--scope"), "\"--scope\""); + assert.equal(isSafeWindowsCmdShim("C:\\Program Files\\Agency\\agency.cmd"), true); + assert.equal(isSafeWindowsCmdShim("C:\\Users\\%USERNAME%\\agency.cmd"), false); +}); + +test("executes a Windows cmd shim with safe quoting", { + skip: process.platform !== "win32", +}, () => { + const root = mkdtempSync(join(tmpdir(), "workshop-profile-")); + const shimDir = join(root, "Shim Name"); + mkdirSync(shimDir); + const shim = join(shimDir, "copilot.cmd"); + writeFileSync(shim, "@echo off\r\necho {\"plugins\":[]}\r\n"); + + const cmd = join(process.env.SystemRoot, "System32", "cmd.exe"); + const commandLine = `"${[shim, "plugins", "list"] + .map(quoteWindowsCmdArgument) + .join(" ")}"`; + const result = spawnSync(cmd, ["/d", "/s", "/c", commandLine], { + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: true, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\{"plugins":\[\]\}/); +}); + +test("extracts enabled plugin-scoped MCP names and rejects unsafe names", () => { + const names = parsePluginMcpNames(JSON.stringify({ + plugins: [ + { kind: "mcp", name: "teams", scope: "plugin", enabled: true }, + { kind: "mcp", name: "repo-mcp", source: "plugin", enabled: true }, + { kind: "mcp", name: "teams", scope: "plugin", enabled: true }, + { kind: "mcp", name: "disabled", scope: "plugin", enabled: false }, + { kind: "mcp", name: "workspace", scope: "repository", enabled: true }, + { kind: "skill", name: "not-an-mcp", scope: "plugin", enabled: true }, + { kind: "mcp", name: "bad;name", scope: "plugin", enabled: true }, + ], + })); + + assert.deepEqual(names, ["teams", "repo-mcp"]); + assert.deepEqual(parsePluginMcpNames(`Agency startup\n${JSON.stringify({ + plugins: [{ kind: "mcp", name: "ado", scope: "plugin", enabled: true }], + })}`), ["ado"]); + assert.equal(parsePluginMcpNames("not json"), null); + assert.equal(parsePluginMcpNames("{}"), null); +}); + +test("builds an Agency repo profile on top of the existing wrapper", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "C:\\workshop", + useAgency: true, + agencyCommand: "C:\\tools\\agency.exe", + profile: "repo", + pluginMcpNames: ["teams", "ado"], + }), [ + "C:\\tools\\agency.exe", "copilot", "--no-default-mcps", + "--disable-mcp-server", "teams", + "--disable-mcp-server", "ado", + "--add-dir", "C:\\workshop", + ]); +}); + +test("builds a plain Copilot repo profile without Agency-only flags", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: false, + copilotCommand: "/usr/local/bin/copilot", + profile: "repo", + pluginMcpNames: ["calendar"], + }), [ + "/usr/local/bin/copilot", "--name", "cost-desk", + "--disable-mcp-server", "calendar", + "--add-dir", "/workshop", + ]); +}); + +test("connected preserves tools while Agency discovery failure still removes defaults", () => { + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: true, + profile: "connected", + pluginMcpNames: ["teams"], + }), [ + "agency", "copilot", "--add-dir", "/workshop", + ]); + + assert.deepEqual(buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: true, + profile: "repo", + pluginMcpNames: [], + discoverySucceeded: false, + }), [ + "agency", "copilot", "--no-default-mcps", "--add-dir", "/workshop", + ]); +}); diff --git a/plugins/the-workshop/extensions/signals-dashboard/package.json b/plugins/the-workshop/extensions/signals-dashboard/package.json index b1625c1a..6165e97c 100644 --- a/plugins/the-workshop/extensions/signals-dashboard/package.json +++ b/plugins/the-workshop/extensions/signals-dashboard/package.json @@ -1,12 +1,15 @@ { "name": "signals-dashboard", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "extension.mjs", + "scripts": { + "test": "node --test launch-profile.test.mjs" + }, "dependencies": { "@github/copilot-sdk": "latest" }, - "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", "keywords": [ "agent-signals", "dashboard",