Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
},
{
"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",
"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",
Comment thread
nicolehaugen marked this conversation as resolved.
"source": "plugins/spec-kit-copilot-wizard"
}
]
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down Expand Up @@ -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`](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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 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.

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": "<id>" }` and asks the child
session to write the handoff JSON to
`speckit-canvas-designer/handoffs/<id>/handoff.json` **under that child's
`session.workspacePath`** before opening the canvas. The provider never writes
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
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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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, optionally with a validated Wizard handoff.",
inputSchema: {
type: "object", additionalProperties: false,
properties: { handoffId: {
type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$",
} },
},
open: async (ctx) => {
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 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);
if (!entry) return;
servers.delete(instanceId);
await entry.close();
},
})],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createHash, timingSafeEqual } from "node:crypto";
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;
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, openFile = open) {
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");
let file;
try {
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;
}
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();
}
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

export function shellHtml(handoff = null) {
const summary = handoff
? `<div class="summary" role="status">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</div>`
: '<p>No Wizard handoff is attached yet.</p>';
return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Canvas Designer</title>
<style>
body { margin: 0; background: var(--background-color-default, #fff);
color: var(--text-color-default, #1f2328);
font: var(--text-body-medium, 14px)/var(--leading-body-medium, 20px)
var(--font-sans, system-ui, sans-serif); }
main { padding: 28px; max-width: 560px; }
h1 { font-size: var(--text-title-large, 26px); line-height: var(--leading-title-large, 32px); }
p { color: var(--text-color-muted, #59636e); }
.summary { margin-top: 24px; border: 1px solid var(--border-color-default, #d1d9e0);
border-radius: 8px; padding: 16px; }
</style>
</head>
<body><main><h1>Canvas Designer</h1>
<p>Your Designer session is ready. Design pages will be added in a later update.</p>
${summary}
</main></body></html>`;
}

export async function startShell(handoff = null) {
const token = randomBytes(24).toString("hex");
const server = createServer((req, res) => {
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);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)
|| req.method !== "GET" || url.pathname !== "/") {
res.writeHead(404).end();
return;
}
Comment thread
nicolehaugen marked this conversation as resolved.
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())),
};
}
Loading
Loading