From 3b7af73709d7df99e2f25580b741bda2b117b4a5 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 20:38:31 -0500 Subject: [PATCH 1/5] Add standalone Canvas Designer provider to Wizard plugin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 2 +- README.md | 3 +- .../speckit-canvas-designer/extension.mjs | 40 +++++ .../speckit-canvas-designer/handoff.mjs | 103 +++++++++++++ .../speckit-canvas-designer/server.mjs | 54 +++++++ .../test/provider.test.mjs | 142 ++++++++++++++++++ plugins/spec-kit-copilot-wizard/plugin.json | 2 +- 7 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 62cbfb06..e4e5fa41 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -35,7 +35,7 @@ { "name": "spec-kit-copilot-wizard", "description": "Adds a guided Spec Kit Wizard canvas that drives the full spec-driven development lifecycle via the spec-kit-copilot skills plugin.", - "version": "0.2.0", + "version": "0.3.0", "source": "plugins/spec-kit-copilot-wizard" } ] diff --git a/README.md b/README.md index e8006a03..ae53a8c7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get star | `spec-kit-copilot-assess` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `assess` extension | | `spec-kit-copilot-bugfix` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `bug` extension | | `spec-kit-copilot-sdd` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the core spec-driven development workflow | -| `spec-kit-copilot-wizard` | 0.2.0 | Copilot App canvas | Optional guided wizard canvas for the full Spec Kit lifecycle | +| `spec-kit-copilot-wizard` | 0.3.0 | Copilot App canvases | Guided wizard and standalone Canvas Designer shell for the Spec Kit lifecycle | The plugins are independently installable and versioned. Install the core skills, the assessment canvas, the bug fix canvas, the spec-driven development canvas, the @@ -102,6 +102,7 @@ own README for full details. | [`bugfix-canvas`](plugins/spec-kit-copilot-bugfix/extensions/bugfix-canvas/README.md) | `spec-kit-copilot-bugfix` | Dashboard for the optional `bug` extension — the assess → fix → test triage pipeline. | | [`sdd-canvas`](plugins/spec-kit-copilot-sdd/extensions/sdd-canvas/README.md) | `spec-kit-copilot-sdd` | Dashboard for the core spec-driven workflow — constitution → specify → clarify → plan → tasks → analyze → checklist → implement. | | [`speckit-wizard-canvas`](plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md) | `spec-kit-copilot-wizard` | Guided wizard for the full Spec Kit lifecycle — setup → constitution → specify → clarify → plan → tasks → analyze → checklist → implement, with preset / extension / composition inspectors. | +| `speckit-canvas-designer` | `spec-kit-copilot-wizard` | Standalone Designer shell; accepts a validated Wizard handoff stored in the opening session's artifacts. Design pages are not yet available. | ### Previews diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs new file mode 100644 index 00000000..758b5be0 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs @@ -0,0 +1,40 @@ +import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension"; +import { readHandoff } from "./handoff.mjs"; +import { startShell } from "./server.mjs"; + +const servers = new Map(); + +const session = await joinSession({ + canvases: [createCanvas({ + id: "speckit-canvas-designer", + displayName: "Spec Kit Canvas Designer", + description: "Open the Designer shell for a validated Wizard handoff.", + inputSchema: { + type: "object", additionalProperties: false, required: ["handoffId"], + properties: { handoffId: { + type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + } }, + }, + open: async (ctx) => { + try { + const handoff = await readHandoff(session.workspacePath, ctx.input.handoffId); + const previous = servers.get(ctx.instanceId); + if (previous?.handoffId === handoff.handoffId) { + return { title: "Spec Kit Canvas Designer", url: previous.url }; + } + const next = await startShell(handoff); + servers.set(ctx.instanceId, { ...next, handoffId: handoff.handoffId }); + if (previous) await previous.close(); + return { title: "Spec Kit Canvas Designer", url: next.url }; + } catch (error) { + throw new CanvasError("designer_handoff_invalid", error.message); + } + }, + onClose: async ({ instanceId }) => { + const entry = servers.get(instanceId); + if (!entry) return; + servers.delete(instanceId); + await entry.close(); + }, + })], +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs new file mode 100644 index 00000000..710f2535 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs @@ -0,0 +1,103 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const HANDOFF_LIMIT = 64 * 1024; +const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const PACKAGE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; +const KINDS = ["presets", "extensions", "bundles"]; +const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +function safeUrl(value) { + if (value === null) return true; + if (typeof value !== "string" || value.length > 2048 || /[\s\x00-\x1f\x7f<>]/.test(value)) { + return false; + } + try { + const url = new URL(value); + return url.protocol === "https:" && !!url.hostname && !url.username && !url.password; + } catch { return false; } +} + +export function validateHandoffId(id) { + if (typeof id !== "string" || !ID.test(id)) throw new Error("Invalid Designer handoff ID"); + return id; +} + +export function fingerprint(data) { + return createHash("sha256").update(JSON.stringify(data)).digest("hex"); +} + +export function validateHandoff(handoff, id) { + validateHandoffId(id); + if (!record(handoff) + || Object.keys(handoff).some((key) => + !["schemaVersion", "handoffId", "workflow", "selections", "sourceFingerprint"].includes(key)) + || handoff.schemaVersion !== 1 || handoff.handoffId !== id + || !record(handoff.workflow) + || Object.keys(handoff.workflow).some((key) => key !== "selectedPhases") + || !Array.isArray(handoff.workflow.selectedPhases) + || handoff.workflow.selectedPhases.length > 30 + || !handoff.workflow.selectedPhases.every((phase) => typeof phase === "string" && PACKAGE.test(phase)) + || !record(handoff.selections) + || Object.keys(handoff.selections).some((kind) => !KINDS.includes(kind)) + || KINDS.some((kind) => !Array.isArray(handoff.selections[kind]) + || handoff.selections[kind].length > 40 + || new Set(handoff.selections[kind].map((item) => + `${item?.source}:${item?.id}`)).size !== handoff.selections[kind].length + || !handoff.selections[kind].every((item) => record(item) + && Object.keys(item).every((key) => + ["id", "source", "approved", "version", "downloadUrl"].includes(key)) + && typeof item.id === "string" && PACKAGE.test(item.id) + && typeof item.source === "string" && PACKAGE.test(item.source) + && item.approved === true + && (item.version === null || (typeof item.version === "string" + && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/.test(item.version))) + && safeUrl(item.downloadUrl))) + || typeof handoff.sourceFingerprint !== "string" + || !/^[a-f0-9]{64}$/.test(handoff.sourceFingerprint) + || Buffer.byteLength(JSON.stringify(handoff)) > HANDOFF_LIMIT) { + throw new Error("Invalid Designer handoff"); + } + const expected = Buffer.from(fingerprint({ + workflow: handoff.workflow, selections: handoff.selections, + }), "hex"); + if (!timingSafeEqual(expected, Buffer.from(handoff.sourceFingerprint, "hex"))) { + throw new Error("Designer handoff fingerprint mismatch"); + } + return handoff; +} + +export async function readHandoff(workspacePath, handoffId) { + const id = validateHandoffId(handoffId); + if (typeof workspacePath !== "string" || !workspacePath.trim()) { + throw new Error("Designer session workspace is unavailable"); + } + const root = await realpath(workspacePath); + const folder = join(root, "speckit-canvas-designer", "handoffs", id); + const actual = await realpath(folder); + const rel = relative(root, actual); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`) + || isAbsolute(rel) || actual !== folder) { + throw new Error("Designer handoff escapes session artifacts"); + } + const path = join(folder, "handoff.json"); + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > HANDOFF_LIMIT) { + throw new Error("Invalid Designer handoff file"); + } + const text = await readFile(path, "utf8"); + if (Buffer.byteLength(text) > HANDOFF_LIMIT) throw new Error("Oversized Designer handoff"); + let handoff; + try { handoff = JSON.parse(text); } + catch { throw new Error("Malformed Designer handoff"); } + return validateHandoff(handoff, id); +} + +export function handoffDirectory(workspacePath, id) { + validateHandoffId(id); + if (typeof workspacePath !== "string" || !workspacePath.trim()) { + throw new Error("Designer session workspace is unavailable"); + } + return resolve(workspacePath, "speckit-canvas-designer", "handoffs", id); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs new file mode 100644 index 00000000..0e51bb57 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs @@ -0,0 +1,54 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; + +export function shellHtml(handoff) { + const count = ["presets", "extensions", "bundles"] + .reduce((total, kind) => total + handoff.selections[kind].length, 0); + return ` + + +Canvas Designer + + +

Canvas Designer

+

Your Designer session is ready. Design pages will be added in a later update.

+
Wizard handoff received · ${handoff.workflow.selectedPhases.length} phases · ${count} design customizations queued for future installation
+
`; +} + +export async function startShell(handoff) { + const token = randomBytes(24).toString("hex"); + const server = createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + const supplied = url.searchParams.get("token"); + const actual = typeof supplied === "string" ? Buffer.from(supplied) : Buffer.alloc(0); + const expected = Buffer.from(token); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected) + || req.method !== "GET" || url.pathname !== "/") { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }); + res.end(shellHtml(handoff)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return { + url: `http://127.0.0.1:${server.address().port}/?token=${token}`, + close: () => new Promise((resolve, reject) => server.close((error) => + error ? reject(error) : resolve())), + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs new file mode 100644 index 00000000..8ba665ea --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + fingerprint, handoffDirectory, HANDOFF_LIMIT, readHandoff, validateHandoff, + validateHandoffId, +} from "../handoff.mjs"; +import { shellHtml, startShell } from "../server.mjs"; + +const ID = "designer_1"; + +function validHandoff(id = ID) { + const workflow = { selectedPhases: ["specify", "plan"] }; + const selections = { + presets: [{ id: "theme", source: "copilot", approved: true, + version: "1.2.3", downloadUrl: "https://example.com/theme" }], + extensions: [], + bundles: [{ id: "starter", source: "default", approved: true, + version: null, downloadUrl: null }], + }; + return { schemaVersion: 1, handoffId: id, workflow, selections, + sourceFingerprint: fingerprint({ workflow, selections }) }; +} + +async function fixture(t) { + const workspace = await mkdtemp(join(tmpdir(), "speckit-designer-test-")); + t.after(() => rm(workspace, { recursive: true, force: true })); + return workspace; +} + +async function saveHandoff(workspace, handoff = validHandoff()) { + const directory = handoffDirectory(workspace, handoff.handoffId); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "handoff.json"), JSON.stringify(handoff)); + return directory; +} + +test("handoff validates bounded IDs, shape, URLs and fingerprint", () => { + const good = validHandoff(); + assert.equal(validateHandoff(good, ID), good); + for (const id of ["", "../escape", "space here", "x".repeat(129), null]) { + assert.throws(() => validateHandoffId(id), /Invalid Designer handoff ID/); + } + assert.throws(() => validateHandoff(good, "different"), /Invalid Designer handoff/); + const mutate = (change) => { + const copy = structuredClone(good); + change(copy); + copy.sourceFingerprint = fingerprint({ workflow: copy.workflow, selections: copy.selections }); + return copy; + }; + const invalid = [ + mutate((copy) => { copy.extra = "unexpected"; }), + mutate((copy) => { copy.workflow.selectedPhases = Array(31).fill("plan"); }), + mutate((copy) => { copy.selections.presets.push({ ...copy.selections.presets[0] }); }), + mutate((copy) => { copy.selections.presets[0].downloadUrl = "http://example.com"; }), + mutate((copy) => { copy.selections.presets[0].downloadUrl = "https://user:pass@example.com"; }), + mutate((copy) => { copy.selections.bundles[0].approved = false; }), + mutate((copy) => { delete copy.selections.extensions; }), + mutate((copy) => { copy.selections.presets[0].id = "../escape"; }), + ]; + for (const handoff of invalid) { + assert.throws(() => validateHandoff(handoff, ID), /Invalid Designer handoff/); + } + const changed = structuredClone(good); + changed.workflow.selectedPhases.push("tasks"); + assert.throws(() => validateHandoff(changed, ID), /fingerprint mismatch/); +}); + +test("handoff reads only validated artifacts from its session workspace", async (t) => { + const workspace = await fixture(t); + const handoff = validHandoff(); + const directory = await saveHandoff(workspace, handoff); + assert.deepEqual(await readHandoff(workspace, ID), handoff); + await assert.rejects(readHandoff(workspace, "../escape"), /Invalid Designer handoff ID/); + await assert.rejects(readHandoff("", ID), /Designer session workspace is unavailable/); + await assert.rejects(readHandoff(workspace, "other"), { code: "ENOENT" }); + + const path = join(directory, "handoff.json"); + await writeFile(path, "{broken"); + await assert.rejects(readHandoff(workspace, ID), /Malformed Designer handoff/); + await writeFile(path, JSON.stringify({ ...handoff, sourceFingerprint: "0".repeat(64) })); + await assert.rejects(readHandoff(workspace, ID), /fingerprint mismatch/); + await writeFile(path, "x".repeat(HANDOFF_LIMIT + 1)); + await assert.rejects(readHandoff(workspace, ID), /Invalid Designer handoff file/); + + const outside = await fixture(t); + await saveHandoff(outside); + await rm(directory, { recursive: true }); + try { + await symlink(handoffDirectory(outside, ID), directory, + process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error.code)) throw error; + t.diagnostic("Windows symlink creation is not permitted; traversal assertion skipped"); + return; + } + await assert.rejects(readHandoff(workspace, ID), /Designer handoff escapes session artifacts/); +}); + +test("handoff rejects symlinked file without following it", async (t) => { + const workspace = await fixture(t); + const directory = await saveHandoff(workspace); + const path = join(directory, "handoff.json"); + const target = join(workspace, "outside.json"); + await writeFile(target, JSON.stringify(validHandoff())); + await rm(path); + try { + await symlink(target, path, "file"); + } catch (error) { + if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error.code)) throw error; + t.diagnostic("Windows symlink creation is not permitted; file assertion skipped"); + return; + } + await assert.rejects(readHandoff(workspace, ID), /Invalid Designer handoff file/); +}); + +test("shell renders counts without echoing handoff content and restricts HTTP access", async (t) => { + const handoff = validHandoff(); + assert.match(shellHtml(handoff), /2 phases · 2 design customizations queued/); + assert.doesNotMatch(shellHtml(handoff), /example\.com|starter|theme/); + const shell = await startShell(handoff); + t.after(() => shell.close()); + const url = new URL(shell.url); + assert.equal(url.hostname, "127.0.0.1"); + assert.match(url.searchParams.get("token"), /^[a-f0-9]{48}$/); + const good = await fetch(shell.url); + assert.equal(good.status, 200); + assert.match(good.headers.get("content-type"), /text\/html/); + assert.equal(good.headers.get("cache-control"), "no-store"); + assert.equal(good.headers.get("x-content-type-options"), "nosniff"); + assert.match(await good.text(), /Canvas Designer/); + for (const [address, options] of [ + [url.origin, undefined], + [`${url.origin}/?token=wrong`, undefined], + [`${url.origin}/other?token=${url.searchParams.get("token")}`, undefined], + [shell.url, { method: "POST" }], + ]) { + assert.equal((await fetch(address, options)).status, 404); + } +}); diff --git a/plugins/spec-kit-copilot-wizard/plugin.json b/plugins/spec-kit-copilot-wizard/plugin.json index ee986408..1f5a2763 100644 --- a/plugins/spec-kit-copilot-wizard/plugin.json +++ b/plugins/spec-kit-copilot-wizard/plugin.json @@ -1,7 +1,7 @@ { "name": "spec-kit-copilot-wizard", "description": "A GitHub Copilot App canvas that provides a guided wizard for the Spec Kit spec-driven development lifecycle.", - "version": "0.2.0", + "version": "0.3.0", "author": { "name": "GitHub", "url": "https://github.com/github/spec-kit-copilot" From 1feee80d44863cede9ff0321e7f8dec51d1dbc8a Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 20:53:43 -0500 Subject: [PATCH 2/5] Allow empty Designer shell and harden handoff reading Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 2 +- README.md | 4 +- .../speckit-canvas-designer/README.md | 24 +++++ .../speckit-canvas-designer/extension.mjs | 30 +++--- .../speckit-canvas-designer/handoff.mjs | 39 +++++-- .../speckit-canvas-designer/server.mjs | 14 ++- .../test/provider.test.mjs | 100 +++++++++++++++++- plugins/spec-kit-copilot-wizard/plugin.json | 2 +- 8 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index e4e5fa41..1be7aa53 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -34,7 +34,7 @@ }, { "name": "spec-kit-copilot-wizard", - "description": "Adds a guided Spec Kit Wizard canvas that drives the full spec-driven development lifecycle via the spec-kit-copilot skills plugin.", + "description": "Adds the guided Spec Kit Wizard canvas and an under-development Canvas Designer shell that is not ready for use.", "version": "0.3.0", "source": "plugins/spec-kit-copilot-wizard" } diff --git a/README.md b/README.md index ae53a8c7..b490be07 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get star | `spec-kit-copilot-assess` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `assess` extension | | `spec-kit-copilot-bugfix` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `bug` extension | | `spec-kit-copilot-sdd` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the core spec-driven development workflow | -| `spec-kit-copilot-wizard` | 0.3.0 | Copilot App canvases | Guided wizard and standalone Canvas Designer shell for the Spec Kit lifecycle | +| `spec-kit-copilot-wizard` | 0.3.0 | Copilot App canvases | Guided wizard and an under-development Canvas Designer shell (not ready for use) | The plugins are independently installable and versioned. Install the core skills, the assessment canvas, the bug fix canvas, the spec-driven development canvas, the @@ -102,7 +102,7 @@ own README for full details. | [`bugfix-canvas`](plugins/spec-kit-copilot-bugfix/extensions/bugfix-canvas/README.md) | `spec-kit-copilot-bugfix` | Dashboard for the optional `bug` extension — the assess → fix → test triage pipeline. | | [`sdd-canvas`](plugins/spec-kit-copilot-sdd/extensions/sdd-canvas/README.md) | `spec-kit-copilot-sdd` | Dashboard for the core spec-driven workflow — constitution → specify → clarify → plan → tasks → analyze → checklist → implement. | | [`speckit-wizard-canvas`](plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md) | `spec-kit-copilot-wizard` | Guided wizard for the full Spec Kit lifecycle — setup → constitution → specify → clarify → plan → tasks → analyze → checklist → implement, with preset / extension / composition inspectors. | -| `speckit-canvas-designer` | `spec-kit-copilot-wizard` | Standalone Designer shell; accepts a validated Wizard handoff stored in the opening session's artifacts. Design pages are not yet available. | +| [`speckit-canvas-designer`](plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md) | `spec-kit-copilot-wizard` | Under development; not ready for use. The standalone shell opens empty or with a validated Wizard handoff stored in the opening session's artifacts. | ### Previews diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md new file mode 100644 index 00000000..6246e899 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md @@ -0,0 +1,24 @@ +# Spec Kit Canvas Designer shell + +This extension is **under development and not ready for use**. It ships inside +the `spec-kit-copilot-wizard` plugin. Installing that plugin registers both the +Wizard and Designer canvases. + +Open `speckit-canvas-designer` without input (or with `{}`) for an empty shell. +Design pages are not implemented yet. When launched from the Wizard, supply +`{ "handoffId": "" }` to open with its handoff. The Wizard launch flow asks +the child session to write the handoff JSON to +`speckit-canvas-designer/handoffs//handoff.json` **under that child's +`session.workspacePath`** before opening the canvas. The provider never writes +that file or installs the selected customizations. + +A supplied ID must match the bounded handoff ID pattern; the provider checks the +handoff structure, fingerprint, size, and session-artifact boundary. A supplied ID +with a missing or invalid file is an error, not an empty shell. The HTTP shell +binds to loopback and requires an unguessable URL token. + +Run the provider tests with: + +```bash +node --test plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs +``` diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs index 758b5be0..c0d12532 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/extension.mjs @@ -8,27 +8,31 @@ const session = await joinSession({ canvases: [createCanvas({ id: "speckit-canvas-designer", displayName: "Spec Kit Canvas Designer", - description: "Open the Designer shell for a validated Wizard handoff.", + description: "Open the Designer shell, optionally with a validated Wizard handoff.", inputSchema: { - type: "object", additionalProperties: false, required: ["handoffId"], + type: "object", additionalProperties: false, properties: { handoffId: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", } }, }, open: async (ctx) => { - try { - const handoff = await readHandoff(session.workspacePath, ctx.input.handoffId); - const previous = servers.get(ctx.instanceId); - if (previous?.handoffId === handoff.handoffId) { - return { title: "Spec Kit Canvas Designer", url: previous.url }; + const handoffId = ctx.input?.handoffId; + let handoff = null; + if (handoffId !== undefined) { + try { + handoff = await readHandoff(session.workspacePath, handoffId); + } catch (error) { + throw new CanvasError("designer_handoff_invalid", error.message); } - const next = await startShell(handoff); - servers.set(ctx.instanceId, { ...next, handoffId: handoff.handoffId }); - if (previous) await previous.close(); - return { title: "Spec Kit Canvas Designer", url: next.url }; - } catch (error) { - throw new CanvasError("designer_handoff_invalid", error.message); } + const previous = servers.get(ctx.instanceId); + if (previous && previous.handoffId === handoffId) { + return { title: "Spec Kit Canvas Designer", url: previous.url }; + } + const next = await startShell(handoff); + servers.set(ctx.instanceId, { ...next, handoffId }); + if (previous) await previous.close(); + return { title: "Spec Kit Canvas Designer", url: next.url }; }, onClose: async ({ instanceId }) => { const entry = servers.get(instanceId); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs index 710f2535..0749db32 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs @@ -1,5 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; -import { lstat, readFile, realpath } from "node:fs/promises"; +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; export const HANDOFF_LIMIT = 64 * 1024; @@ -68,7 +69,7 @@ export function validateHandoff(handoff, id) { return handoff; } -export async function readHandoff(workspacePath, handoffId) { +export async function readHandoff(workspacePath, handoffId, openFile = open) { const id = validateHandoffId(handoffId); if (typeof workspacePath !== "string" || !workspacePath.trim()) { throw new Error("Designer session workspace is unavailable"); @@ -82,12 +83,36 @@ export async function readHandoff(workspacePath, handoffId) { throw new Error("Designer handoff escapes session artifacts"); } const path = join(folder, "handoff.json"); - const stat = await lstat(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > HANDOFF_LIMIT) { - throw new Error("Invalid Designer handoff file"); + let file; + try { + file = await openFile(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + } catch (error) { + if (error.code === "ELOOP") throw new Error("Invalid Designer handoff file", { cause: error }); + throw error; + } + let text; + try { + const [stat, pathStat, currentFolder] = await Promise.all([ + file.stat(), lstat(path), realpath(folder), + ]); + if (currentFolder !== folder) throw new Error("Designer handoff escapes session artifacts"); + if (!stat.isFile() || !pathStat.isFile() || pathStat.isSymbolicLink() + || stat.dev !== pathStat.dev || stat.ino !== pathStat.ino + || stat.size > HANDOFF_LIMIT) { + throw new Error("Invalid Designer handoff file"); + } + const bytes = Buffer.alloc(HANDOFF_LIMIT + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await file.read(bytes, length, bytes.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > HANDOFF_LIMIT) throw new Error("Oversized Designer handoff"); + text = bytes.toString("utf8", 0, length); + } finally { + await file.close(); } - const text = await readFile(path, "utf8"); - if (Buffer.byteLength(text) > HANDOFF_LIMIT) throw new Error("Oversized Designer handoff"); let handoff; try { handoff = JSON.parse(text); } catch { throw new Error("Malformed Designer handoff"); } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs index 0e51bb57..eec86360 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs @@ -1,9 +1,13 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; -export function shellHtml(handoff) { - const count = ["presets", "extensions", "bundles"] - .reduce((total, kind) => total + handoff.selections[kind].length, 0); +export function shellHtml(handoff = null) { + const summary = handoff + ? `
Wizard handoff received · ${handoff.workflow.selectedPhases.length} phases · ${ + ["presets", "extensions", "bundles"].reduce((total, kind) => + total + handoff.selections[kind].length, 0) + } design customizations queued for future installation
` + : '

No Wizard handoff is attached yet.

'; return ` @@ -22,11 +26,11 @@ export function shellHtml(handoff) {

Canvas Designer

Your Designer session is ready. Design pages will be added in a later update.

-
Wizard handoff received · ${handoff.workflow.selectedPhases.length} phases · ${count} design customizations queued for future installation
+${summary}
`; } -export async function startShell(handoff) { +export async function startShell(handoff = null) { const token = randomBytes(24).toString("hex"); const server = createServer((req, res) => { const url = new URL(req.url, "http://127.0.0.1"); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs index 8ba665ea..8333cf68 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { copyFile, mkdtemp, mkdir, open, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { fingerprint, handoffDirectory, HANDOFF_LIMIT, readHandoff, validateHandoff, validateHandoffId, @@ -116,6 +117,48 @@ test("handoff rejects symlinked file without following it", async (t) => { await assert.rejects(readHandoff(workspace, ID), /Invalid Designer handoff file/); }); +test("handoff rejects a parent directory replaced during file open", async (t) => { + const workspace = await fixture(t); + const outside = await fixture(t); + const directory = await saveHandoff(workspace); + await saveHandoff(outside); + const backup = `${directory}-original`; + let replaced = false; + try { + await assert.rejects(readHandoff(workspace, ID, async (path, flags) => { + await rename(directory, backup); + try { + await symlink(handoffDirectory(outside, ID), directory, + process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + await rename(backup, directory); + throw error; + } + replaced = true; + return open(path, flags); + }), /Designer handoff escapes session artifacts/); + } catch (error) { + if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error.code)) throw error; + t.diagnostic("Windows symlink creation is not permitted; race assertion skipped"); + } finally { + if (replaced) { + await rm(directory, { recursive: true }); + await rename(backup, directory); + } + } +}); + +test("handoff rejects a different opened file even if the path still passes validation", async (t) => { + const workspace = await fixture(t); + const outside = await fixture(t); + await saveHandoff(workspace); + const outsideDirectory = await saveHandoff(outside); + await assert.rejects( + readHandoff(workspace, ID, (_path, flags) => open(join(outsideDirectory, "handoff.json"), flags)), + /Invalid Designer handoff file/, + ); +}); + test("shell renders counts without echoing handoff content and restricts HTTP access", async (t) => { const handoff = validHandoff(); assert.match(shellHtml(handoff), /2 phases · 2 design customizations queued/); @@ -140,3 +183,58 @@ test("shell renders counts without echoing handoff content and restricts HTTP ac assert.equal((await fetch(address, options)).status, 404); } }); + +test("empty shell renders without a handoff and keeps the token gate", async (t) => { + const html = shellHtml(); + assert.match(html, /No Wizard handoff is attached yet/); + assert.doesNotMatch(html, /Wizard handoff received|customizations queued/); + const shell = await startShell(); + t.after(() => shell.close()); + assert.match(await (await fetch(shell.url)).text(), /No Wizard handoff is attached yet/); + const url = new URL(shell.url); + assert.equal((await fetch(url.origin)).status, 404); +}); + +test("canvas opens empty without an ID, then opens a validated handoff", async (t) => { + const workspace = await fixture(t); + const source = fileURLToPath(new URL("../", import.meta.url)); + const extension = join(workspace, "provider"); + const sdk = join(extension, "node_modules", "@github", "copilot-sdk"); + await mkdir(sdk, { recursive: true }); + for (const file of ["extension.mjs", "handoff.mjs", "server.mjs"]) { + await copyFile(join(source, file), join(extension, file)); + } + await writeFile(join(sdk, "package.json"), JSON.stringify({ + name: "@github/copilot-sdk", type: "module", exports: { "./extension": "./extension.mjs" }, + })); + await writeFile(join(sdk, "extension.mjs"), ` + export const createCanvas = (canvas) => canvas; + export class CanvasError extends Error { + constructor(code, message) { super(message); this.code = code; } + } + export const joinSession = async ({ canvases }) => { + globalThis.__designerTestCanvas = canvases[0]; + return { workspacePath: ${JSON.stringify(workspace)} }; + }; + `); + await import(pathToFileURL(join(extension, "extension.mjs")).href); + const canvas = globalThis.__designerTestCanvas; + delete globalThis.__designerTestCanvas; + assert.deepEqual(canvas.inputSchema.required, undefined); + assert.deepEqual(canvas.inputSchema.properties.handoffId.type, "string"); + + try { + const empty = await canvas.open({ instanceId: "same", input: {} }); + assert.match(await (await fetch(empty.url)).text(), /No Wizard handoff is attached yet/); + assert.equal((await canvas.open({ instanceId: "same" })).url, empty.url); + await assert.rejects(canvas.open({ instanceId: "same", input: { handoffId: ID } }), + (error) => error.code === "designer_handoff_invalid"); + await saveHandoff(workspace); + const filled = await canvas.open({ instanceId: "same", input: { handoffId: ID } }); + assert.notEqual(filled.url, empty.url); + assert.match(await (await fetch(filled.url)).text(), /Wizard handoff received/); + assert.equal((await canvas.open({ instanceId: "same", input: { handoffId: ID } })).url, filled.url); + } finally { + await canvas.onClose({ instanceId: "same" }); + } +}); diff --git a/plugins/spec-kit-copilot-wizard/plugin.json b/plugins/spec-kit-copilot-wizard/plugin.json index 1f5a2763..b450daa0 100644 --- a/plugins/spec-kit-copilot-wizard/plugin.json +++ b/plugins/spec-kit-copilot-wizard/plugin.json @@ -1,6 +1,6 @@ { "name": "spec-kit-copilot-wizard", - "description": "A GitHub Copilot App canvas that provides a guided wizard for the Spec Kit spec-driven development lifecycle.", + "description": "GitHub Copilot App canvases for the guided Spec Kit Wizard and an under-development Canvas Designer shell that is not ready for use.", "version": "0.3.0", "author": { "name": "GitHub", From 39372a1890339c40a48c2c2c829133d71ba7685e Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 20:56:04 -0500 Subject: [PATCH 3/5] Clarify Wizard-first Canvas Designer handoff Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 2 +- README.md | 4 ++-- .../extensions/speckit-canvas-designer/README.md | 16 +++++++++++----- plugins/spec-kit-copilot-wizard/plugin.json | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 1be7aa53..34c6d671 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -34,7 +34,7 @@ }, { "name": "spec-kit-copilot-wizard", - "description": "Adds the guided Spec Kit Wizard canvas and an under-development Canvas Designer shell that is not ready for use.", + "description": "Adds the guided Spec Kit Wizard and an under-development Designer shell for future workflow-specific canvases based on Wizard pipeline and customization handoffs; Designer is not ready for use.", "version": "0.3.0", "source": "plugins/spec-kit-copilot-wizard" } diff --git a/README.md b/README.md index b490be07..11352d79 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get star | `spec-kit-copilot-assess` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `assess` extension | | `spec-kit-copilot-bugfix` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `bug` extension | | `spec-kit-copilot-sdd` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the core spec-driven development workflow | -| `spec-kit-copilot-wizard` | 0.3.0 | Copilot App canvases | Guided wizard and an under-development Canvas Designer shell (not ready for use) | +| `spec-kit-copilot-wizard` | 0.3.0 | Copilot App canvases | Guided wizard and an under-development Designer shell for future workflow-specific canvases from Wizard handoffs (not ready for use) | The plugins are independently installable and versioned. Install the core skills, the assessment canvas, the bug fix canvas, the spec-driven development canvas, the @@ -102,7 +102,7 @@ own README for full details. | [`bugfix-canvas`](plugins/spec-kit-copilot-bugfix/extensions/bugfix-canvas/README.md) | `spec-kit-copilot-bugfix` | Dashboard for the optional `bug` extension — the assess → fix → test triage pipeline. | | [`sdd-canvas`](plugins/spec-kit-copilot-sdd/extensions/sdd-canvas/README.md) | `spec-kit-copilot-sdd` | Dashboard for the core spec-driven workflow — constitution → specify → clarify → plan → tasks → analyze → checklist → implement. | | [`speckit-wizard-canvas`](plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md) | `spec-kit-copilot-wizard` | Guided wizard for the full Spec Kit lifecycle — setup → constitution → specify → clarify → plan → tasks → analyze → checklist → implement, with preset / extension / composition inspectors. | -| [`speckit-canvas-designer`](plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md) | `spec-kit-copilot-wizard` | Under development; not ready for use. The standalone shell opens empty or with a validated Wizard handoff stored in the opening session's artifacts. | +| [`speckit-canvas-designer`](plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md) | `spec-kit-copilot-wizard` | Under development; not ready for use. Primarily launched from the Wizard with its configured pipeline and selected presets, extensions, and bundles as a handoff for future workflow-specific canvas generation. Direct opening shows only an empty shell. | ### Previews diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md index 6246e899..176cbbd8 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/README.md @@ -4,13 +4,19 @@ This extension is **under development and not ready for use**. It ships inside the `spec-kit-copilot-wizard` plugin. Installing that plugin registers both the Wizard and Designer canvases. -Open `speckit-canvas-designer` without input (or with `{}`) for an empty shell. -Design pages are not implemented yet. When launched from the Wizard, supply -`{ "handoffId": "" }` to open with its handoff. The Wizard launch flow asks -the child session to write the handoff JSON to +The intended primary flow launches Designer **from the Wizard**. The Wizard +provides a handoff containing its configured pipeline and selected presets, +extensions, and bundles; these will guide the generation of a workflow-specific +canvas in a later update. For now, Designer displays only a shell (with a +handoff summary when provided): it does not generate a canvas or install +selected customizations. + +The Wizard launch flow supplies `{ "handoffId": "" }` and asks the child +session to write the handoff JSON to `speckit-canvas-designer/handoffs//handoff.json` **under that child's `session.workspacePath`** before opening the canvas. The provider never writes -that file or installs the selected customizations. +that file. Opening `speckit-canvas-designer` without input (or with `{}`) +shows an empty shell, not a generated canvas. A supplied ID must match the bounded handoff ID pattern; the provider checks the handoff structure, fingerprint, size, and session-artifact boundary. A supplied ID diff --git a/plugins/spec-kit-copilot-wizard/plugin.json b/plugins/spec-kit-copilot-wizard/plugin.json index b450daa0..ea374294 100644 --- a/plugins/spec-kit-copilot-wizard/plugin.json +++ b/plugins/spec-kit-copilot-wizard/plugin.json @@ -1,6 +1,6 @@ { "name": "spec-kit-copilot-wizard", - "description": "GitHub Copilot App canvases for the guided Spec Kit Wizard and an under-development Canvas Designer shell that is not ready for use.", + "description": "Guided Spec Kit Wizard canvas and an under-development Designer shell for future workflow-specific canvases based on Wizard pipeline and customization handoffs; Designer is not ready for use.", "version": "0.3.0", "author": { "name": "GitHub", From 8e03a8f44c2e3871606fdb3d3fba73dbeb13aedf Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 21:07:41 -0500 Subject: [PATCH 4/5] Open Designer handoffs without blocking on FIFOs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../speckit-canvas-designer/handoff.mjs | 3 +- .../test/provider.test.mjs | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs index 0749db32..d0eeaabb 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/handoff.mjs @@ -85,7 +85,8 @@ export async function readHandoff(workspacePath, handoffId, openFile = open) { const path = join(folder, "handoff.json"); let file; try { - file = await openFile(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + file = await openFile(path, constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); } catch (error) { if (error.code === "ELOOP") throw new Error("Invalid Designer handoff file", { cause: error }); throw error; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs index 8333cf68..cdf535f0 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { copyFile, mkdtemp, mkdir, open, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -159,6 +160,34 @@ test("handoff rejects a different opened file even if the path still passes vali ); }); +test("handoff rejects a FIFO promptly instead of waiting for a writer", { + skip: process.platform === "win32", +}, async (t) => { + const workspace = await fixture(t); + const directory = handoffDirectory(workspace, ID); + await mkdir(directory, { recursive: true }); + const fifo = join(directory, "handoff.json"); + const created = spawnSync("mkfifo", [fifo], { encoding: "utf8" }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + + const script = ` + import { readHandoff } from ${JSON.stringify(new URL("../handoff.mjs", import.meta.url).href)}; + try { + await readHandoff(process.argv[1], ${JSON.stringify(ID)}); + process.exitCode = 1; + } catch (error) { + if (!/Invalid Designer handoff file/.test(error.message)) { + console.error(error); + process.exitCode = 2; + } + } + `; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", script, workspace], + { timeout: 3000, encoding: "utf8" }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr); +}); + test("shell renders counts without echoing handoff content and restricts HTTP access", async (t) => { const handoff = validHandoff(); assert.match(shellHtml(handoff), /2 phases · 2 design customizations queued/); From 7e080df8021a226a50f44420c07fee7591600b44 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 21:14:51 -0500 Subject: [PATCH 5/5] Reject malformed Designer shell request targets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../speckit-canvas-designer/server.mjs | 8 +++++++- .../test/provider.test.mjs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs index eec86360..bb67f268 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/server.mjs @@ -33,7 +33,13 @@ ${summary} export async function startShell(handoff = null) { const token = randomBytes(24).toString("hex"); const server = createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); + let url; + try { + url = new URL(req.url, "http://127.0.0.1"); + } catch { + res.writeHead(404).end(); + return; + } const supplied = url.searchParams.get("token"); const actual = typeof supplied === "string" ? Buffer.from(supplied) : Buffer.alloc(0); const expected = Buffer.from(token); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs index cdf535f0..cf405594 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { copyFile, mkdtemp, mkdir, open, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { request } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -213,6 +214,22 @@ test("shell renders counts without echoing handoff content and restricts HTTP ac } }); +test("malformed raw request targets return 404 without stopping the shell", async (t) => { + const shell = await startShell(); + t.after(() => shell.close()); + const url = new URL(shell.url); + const status = await new Promise((resolve, reject) => { + const req = request({ hostname: url.hostname, port: url.port, path: "//[" }, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode)); + }); + req.on("error", reject); + req.end(); + }); + assert.equal(status, 404); + assert.equal((await fetch(shell.url)).status, 200); +}); + test("empty shell renders without a handoff and keeps the token gate", async (t) => { const html = shellHtml(); assert.match(html, /No Wizard handoff is attached yet/);