From 4011c122e398427f9c5b053e06b39eda95f60f87 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Tue, 7 Jul 2026 11:49:25 -0700 Subject: [PATCH] sync canvas-kit to 2026-07-07.4 (SSRF redirect guard, schema validation, safe storage) Re-vendor the canonical canvas-kit into all six example extensions, advancing the vendored copy from 2026-07-05.1 to 2026-07-07.4. This pulls the latest hardening from the create-canvas-app kit: - net: re-check the SSRF guard on every redirect hop - server: cap concurrent SSE subscribers per instance - storage: session-scoped paths + safe atomic saves - validate/runtime: stricter action input validation The stricter input validation surfaced two smoke tests that asserted against the old kit's looser behavior. Update them to the current contract with no behavior change in the extensions themselves: - code-tutor: an out-of-enum clear_cache level is now rejected by the kit's schema gate (the message names the allowed values) before the handler's own guard runs; loosen the assertion and refresh the now-stale comment. - wiki-discover: rating the current card omits `article` instead of passing `article: undefined` (a real client drops undefined keys over JSON, and the kit now type-checks a present-but-undefined optional object). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../code-tutor/canvas-kit/.kit-version.json | 4 +- extensions/code-tutor/canvas-kit/client.mjs | 107 +++++++++---- extensions/code-tutor/canvas-kit/icons.mjs | 13 +- extensions/code-tutor/canvas-kit/net.mjs | 126 +++++++++++++++ extensions/code-tutor/canvas-kit/server.mjs | 62 +++++++- extensions/code-tutor/canvas-kit/storage.mjs | 83 ++++++++-- extensions/code-tutor/canvas-kit/validate.mjs | 146 ++++++++++++++++++ extensions/code-tutor/canvas-kit/version.mjs | 2 +- extensions/code-tutor/canvas.mjs | 4 +- extensions/code-tutor/test/smoke.test.mjs | 4 +- .../canvas-kit/.kit-version.json | 4 +- .../language-tutor/canvas-kit/client.mjs | 107 +++++++++---- .../language-tutor/canvas-kit/icons.mjs | 13 +- extensions/language-tutor/canvas-kit/net.mjs | 126 +++++++++++++++ .../language-tutor/canvas-kit/server.mjs | 62 +++++++- .../language-tutor/canvas-kit/storage.mjs | 83 ++++++++-- .../language-tutor/canvas-kit/validate.mjs | 146 ++++++++++++++++++ .../language-tutor/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../news-aggregator/canvas-kit/client.mjs | 107 +++++++++---- .../news-aggregator/canvas-kit/icons.mjs | 13 +- extensions/news-aggregator/canvas-kit/net.mjs | 126 +++++++++++++++ .../news-aggregator/canvas-kit/server.mjs | 62 +++++++- .../news-aggregator/canvas-kit/storage.mjs | 83 ++++++++-- .../news-aggregator/canvas-kit/validate.mjs | 146 ++++++++++++++++++ .../news-aggregator/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../random-animal/canvas-kit/client.mjs | 107 +++++++++---- extensions/random-animal/canvas-kit/icons.mjs | 13 +- extensions/random-animal/canvas-kit/net.mjs | 126 +++++++++++++++ .../random-animal/canvas-kit/server.mjs | 62 +++++++- .../random-animal/canvas-kit/storage.mjs | 83 ++++++++-- .../random-animal/canvas-kit/validate.mjs | 146 ++++++++++++++++++ .../random-animal/canvas-kit/version.mjs | 2 +- .../stock-ticker/canvas-kit/.kit-version.json | 4 +- extensions/stock-ticker/canvas-kit/client.mjs | 107 +++++++++---- extensions/stock-ticker/canvas-kit/icons.mjs | 13 +- extensions/stock-ticker/canvas-kit/net.mjs | 126 +++++++++++++++ extensions/stock-ticker/canvas-kit/server.mjs | 62 +++++++- .../stock-ticker/canvas-kit/storage.mjs | 83 ++++++++-- .../stock-ticker/canvas-kit/validate.mjs | 146 ++++++++++++++++++ .../stock-ticker/canvas-kit/version.mjs | 2 +- .../canvas-kit/.kit-version.json | 4 +- .../wiki-discover/canvas-kit/client.mjs | 107 +++++++++---- extensions/wiki-discover/canvas-kit/icons.mjs | 13 +- extensions/wiki-discover/canvas-kit/net.mjs | 126 +++++++++++++++ .../wiki-discover/canvas-kit/server.mjs | 62 +++++++- .../wiki-discover/canvas-kit/storage.mjs | 83 ++++++++-- .../wiki-discover/canvas-kit/validate.mjs | 146 ++++++++++++++++++ .../wiki-discover/canvas-kit/version.mjs | 2 +- extensions/wiki-discover/test/smoke.test.mjs | 5 +- 51 files changed, 2980 insertions(+), 291 deletions(-) create mode 100644 extensions/code-tutor/canvas-kit/net.mjs create mode 100644 extensions/code-tutor/canvas-kit/validate.mjs create mode 100644 extensions/language-tutor/canvas-kit/net.mjs create mode 100644 extensions/language-tutor/canvas-kit/validate.mjs create mode 100644 extensions/news-aggregator/canvas-kit/net.mjs create mode 100644 extensions/news-aggregator/canvas-kit/validate.mjs create mode 100644 extensions/random-animal/canvas-kit/net.mjs create mode 100644 extensions/random-animal/canvas-kit/validate.mjs create mode 100644 extensions/stock-ticker/canvas-kit/net.mjs create mode 100644 extensions/stock-ticker/canvas-kit/validate.mjs create mode 100644 extensions/wiki-discover/canvas-kit/net.mjs create mode 100644 extensions/wiki-discover/canvas-kit/validate.mjs diff --git a/extensions/code-tutor/canvas-kit/.kit-version.json b/extensions/code-tutor/canvas-kit/.kit-version.json index 769d8b2..c6fbe1f 100644 --- a/extensions/code-tutor/canvas-kit/.kit-version.json +++ b/extensions/code-tutor/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:31.801Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.151Z", "source": "create-canvas-app/kit" } diff --git a/extensions/code-tutor/canvas-kit/client.mjs b/extensions/code-tutor/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/code-tutor/canvas-kit/client.mjs +++ b/extensions/code-tutor/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/code-tutor/canvas-kit/icons.mjs b/extensions/code-tutor/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/code-tutor/canvas-kit/icons.mjs +++ b/extensions/code-tutor/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/code-tutor/canvas-kit/net.mjs b/extensions/code-tutor/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/code-tutor/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/code-tutor/canvas-kit/server.mjs b/extensions/code-tutor/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/code-tutor/canvas-kit/server.mjs +++ b/extensions/code-tutor/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/code-tutor/canvas-kit/storage.mjs b/extensions/code-tutor/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/code-tutor/canvas-kit/storage.mjs +++ b/extensions/code-tutor/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/code-tutor/canvas-kit/validate.mjs b/extensions/code-tutor/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/code-tutor/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/code-tutor/canvas-kit/version.mjs b/extensions/code-tutor/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/code-tutor/canvas-kit/version.mjs +++ b/extensions/code-tutor/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/code-tutor/canvas.mjs b/extensions/code-tutor/canvas.mjs index 2e684a5..6ec1823 100644 --- a/extensions/code-tutor/canvas.mjs +++ b/extensions/code-tutor/canvas.mjs @@ -1309,7 +1309,9 @@ export const canvasConfig = { }, handler: async ({ input }) => { // L4: an out-of-enum level must NOT silently fall through to deleting the - // whole concept (the kit does no schema validation). Reject it instead. + // whole concept. The kit's schema validation now rejects a bad `level` + // (enum) before this runs; this manual guard stays as a belt-and-suspenders + // fallback that produces the same rejection. let level = null; if (input.level !== undefined) { level = normLevel(input.level, null); diff --git a/extensions/code-tutor/test/smoke.test.mjs b/extensions/code-tutor/test/smoke.test.mjs index 677bb14..9cf385c 100644 --- a/extensions/code-tutor/test/smoke.test.mjs +++ b/extensions/code-tutor/test/smoke.test.mjs @@ -367,7 +367,9 @@ try { await test("clear_cache with an out-of-enum level is rejected (does NOT nuke the concept)", async () => { const { body } = await post(open.url, "clear_cache", { conceptKey: "linear-search", level: "bogus" }); assert.equal(body.ok, false); - assert.match(body.message, /level must be one of/); + // The kit's schema validation rejects the out-of-enum level (its message names + // the allowed values); the handler's own guard would say the same thing. + assert.match(body.message, /must be one of/); // the concept's cached levels survive the rejected call const after = await post(open.url, "lookup_explanation", { conceptKey: "linear-search" }); assert.ok(after.body.result.cachedLevels.length > 0, "concept must not be deleted by a bad level"); diff --git a/extensions/language-tutor/canvas-kit/.kit-version.json b/extensions/language-tutor/canvas-kit/.kit-version.json index bf4f36d..a6be223 100644 --- a/extensions/language-tutor/canvas-kit/.kit-version.json +++ b/extensions/language-tutor/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:31.945Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.286Z", "source": "create-canvas-app/kit" } diff --git a/extensions/language-tutor/canvas-kit/client.mjs b/extensions/language-tutor/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/language-tutor/canvas-kit/client.mjs +++ b/extensions/language-tutor/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/language-tutor/canvas-kit/icons.mjs b/extensions/language-tutor/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/language-tutor/canvas-kit/icons.mjs +++ b/extensions/language-tutor/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/language-tutor/canvas-kit/net.mjs b/extensions/language-tutor/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/language-tutor/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/language-tutor/canvas-kit/server.mjs b/extensions/language-tutor/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/language-tutor/canvas-kit/server.mjs +++ b/extensions/language-tutor/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/language-tutor/canvas-kit/storage.mjs b/extensions/language-tutor/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/language-tutor/canvas-kit/storage.mjs +++ b/extensions/language-tutor/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/language-tutor/canvas-kit/validate.mjs b/extensions/language-tutor/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/language-tutor/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/language-tutor/canvas-kit/version.mjs b/extensions/language-tutor/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/language-tutor/canvas-kit/version.mjs +++ b/extensions/language-tutor/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/news-aggregator/canvas-kit/.kit-version.json b/extensions/news-aggregator/canvas-kit/.kit-version.json index 90bdd70..2f881df 100644 --- a/extensions/news-aggregator/canvas-kit/.kit-version.json +++ b/extensions/news-aggregator/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.084Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.417Z", "source": "create-canvas-app/kit" } diff --git a/extensions/news-aggregator/canvas-kit/client.mjs b/extensions/news-aggregator/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/news-aggregator/canvas-kit/client.mjs +++ b/extensions/news-aggregator/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/news-aggregator/canvas-kit/icons.mjs b/extensions/news-aggregator/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/news-aggregator/canvas-kit/icons.mjs +++ b/extensions/news-aggregator/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/news-aggregator/canvas-kit/net.mjs b/extensions/news-aggregator/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/news-aggregator/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/news-aggregator/canvas-kit/server.mjs b/extensions/news-aggregator/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/news-aggregator/canvas-kit/server.mjs +++ b/extensions/news-aggregator/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/news-aggregator/canvas-kit/storage.mjs b/extensions/news-aggregator/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/news-aggregator/canvas-kit/storage.mjs +++ b/extensions/news-aggregator/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/news-aggregator/canvas-kit/validate.mjs b/extensions/news-aggregator/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/news-aggregator/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/news-aggregator/canvas-kit/version.mjs b/extensions/news-aggregator/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/news-aggregator/canvas-kit/version.mjs +++ b/extensions/news-aggregator/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/random-animal/canvas-kit/.kit-version.json b/extensions/random-animal/canvas-kit/.kit-version.json index fd4b6d3..02f4129 100644 --- a/extensions/random-animal/canvas-kit/.kit-version.json +++ b/extensions/random-animal/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.246Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.548Z", "source": "create-canvas-app/kit" } diff --git a/extensions/random-animal/canvas-kit/client.mjs b/extensions/random-animal/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/random-animal/canvas-kit/client.mjs +++ b/extensions/random-animal/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/random-animal/canvas-kit/icons.mjs b/extensions/random-animal/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/random-animal/canvas-kit/icons.mjs +++ b/extensions/random-animal/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/random-animal/canvas-kit/net.mjs b/extensions/random-animal/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/random-animal/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/random-animal/canvas-kit/server.mjs b/extensions/random-animal/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/random-animal/canvas-kit/server.mjs +++ b/extensions/random-animal/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/random-animal/canvas-kit/storage.mjs b/extensions/random-animal/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/random-animal/canvas-kit/storage.mjs +++ b/extensions/random-animal/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/random-animal/canvas-kit/validate.mjs b/extensions/random-animal/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/random-animal/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/random-animal/canvas-kit/version.mjs b/extensions/random-animal/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/random-animal/canvas-kit/version.mjs +++ b/extensions/random-animal/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/stock-ticker/canvas-kit/.kit-version.json b/extensions/stock-ticker/canvas-kit/.kit-version.json index 80c9034..ca2d3ba 100644 --- a/extensions/stock-ticker/canvas-kit/.kit-version.json +++ b/extensions/stock-ticker/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.390Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.706Z", "source": "create-canvas-app/kit" } diff --git a/extensions/stock-ticker/canvas-kit/client.mjs b/extensions/stock-ticker/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/stock-ticker/canvas-kit/client.mjs +++ b/extensions/stock-ticker/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/stock-ticker/canvas-kit/icons.mjs b/extensions/stock-ticker/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/stock-ticker/canvas-kit/icons.mjs +++ b/extensions/stock-ticker/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/stock-ticker/canvas-kit/net.mjs b/extensions/stock-ticker/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/stock-ticker/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/stock-ticker/canvas-kit/server.mjs b/extensions/stock-ticker/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/stock-ticker/canvas-kit/server.mjs +++ b/extensions/stock-ticker/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/stock-ticker/canvas-kit/storage.mjs b/extensions/stock-ticker/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/stock-ticker/canvas-kit/storage.mjs +++ b/extensions/stock-ticker/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/stock-ticker/canvas-kit/validate.mjs b/extensions/stock-ticker/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/stock-ticker/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/stock-ticker/canvas-kit/version.mjs b/extensions/stock-ticker/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/stock-ticker/canvas-kit/version.mjs +++ b/extensions/stock-ticker/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/wiki-discover/canvas-kit/.kit-version.json b/extensions/wiki-discover/canvas-kit/.kit-version.json index 4685d99..409cb46 100644 --- a/extensions/wiki-discover/canvas-kit/.kit-version.json +++ b/extensions/wiki-discover/canvas-kit/.kit-version.json @@ -1,5 +1,5 @@ { - "version": "2026-07-05.1", - "syncedAt": "2026-07-06T05:14:32.552Z", + "version": "2026-07-07.4", + "syncedAt": "2026-07-07T18:40:29.846Z", "source": "create-canvas-app/kit" } diff --git a/extensions/wiki-discover/canvas-kit/client.mjs b/extensions/wiki-discover/canvas-kit/client.mjs index 75e6e65..6f4cd9c 100644 --- a/extensions/wiki-discover/canvas-kit/client.mjs +++ b/extensions/wiki-discover/canvas-kit/client.mjs @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate } /** - * Mount a canvas view and keep it live. - * @param {object} opts - * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view - * Returns an htm/Preact vnode. Re-invoked on every state push. - * @param {HTMLElement} [opts.mount] defaults to #app or - * @param {(state:any)=>void} [opts.onState] - * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. - * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. - * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + * DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET + * /events SSE, POST /action) and the derived `state`/`connected`, with no Preact + * and no DOM. `mountCanvas` composes this with a render loop; keeping it separate + * makes the reconnect/invoke glue unit-testable without a browser (see + * test/client.test.mjs). Both callbacks receive the latest `(state, connected)`. + * @param {object} [opts] + * @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push + * @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors + * @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global + * @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}} */ -export function mountCanvas({ view, mount, onState, poll } = {}) { - const root = mount || document.getElementById("app") || document.body; - // Preact's render() diffs against — but does not clear — pre-existing DOM in - // the container, so a static no-JS placeholder (e.g.

Loading…

in the - // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty - // root; the view's own loading branch covers the gap until first state. - root.replaceChildren(); +export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) { let state = null; let connected = false; + const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null); async function invoke(actionName, input) { const res = await fetch("./action", { @@ -118,20 +114,16 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { return data.result; } - function rerender() { - render(view({ state, invoke, connected }), root); - } - async function refresh() { try { state = await (await fetch("./state")).json(); - onState?.(state); - rerender(); + onState?.(state, connected); } catch { /* offline; SSE will recover */ } } function connect() { - const es = new EventSource("./events"); + if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works + const es = new ES("./events"); es.onmessage = (e) => { let next; try { @@ -139,20 +131,69 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { } catch { return; // ignore a malformed SSE frame; the next push recovers } - // Update + render OUTSIDE the try so a bug in onState/the view surfaces as - // a real error instead of being silently mislabeled a "malformed frame". + // Update OUTSIDE the try so a bug in onState surfaces as a real error + // instead of being silently mislabeled a "malformed frame". state = next; connected = true; - onState?.(state); - rerender(); + onState?.(state, connected); }; - es.onopen = () => { connected = true; rerender(); }; - es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ }; + es.onopen = () => { connected = true; onConnected?.(connected); }; + es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ }; } refresh(); connect(); + return { + invoke, + refresh, + get state() { return state; }, + get connected() { return connected; }, + }; +} + +/** + * Mount a canvas view and keep it live. + * @param {object} opts + * @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view + * Returns an htm/Preact vnode. Re-invoked on every state push. + * @param {HTMLElement} [opts.mount] defaults to #app or + * @param {(state:any)=>void} [opts.onState] + * @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh. + * For an interval bound to live state, use `pollWhileVisible` in a useEffect instead. + * @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}} + */ +export function mountCanvas({ view, mount, onState, poll } = {}) { + const root = mount || document.getElementById("app") || document.body; + // Preact's render() diffs against — but does not clear — pre-existing DOM in + // the container, so a static no-JS placeholder (e.g.

Loading…

in the + // HTML shell) would linger as a sibling. Clear it once so Preact owns an empty + // root; the view's own loading branch covers the gap until first state. + root.replaceChildren(); + + let latestState = null; + let latestConnected = false; + let client; + + function rerender() { + render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root); + } + + // The transport is DOM-free (connectCanvas); this wrapper only adds the Preact + // render on each state/connection change. + client = connectCanvas({ + onState: (state, connected) => { + latestState = state; + latestConnected = connected; + onState?.(state); + rerender(); + }, + onConnected: (connected) => { + latestConnected = connected; + rerender(); + }, + }); + // Built-in fixed-interval auto-refresh, delegating to the shared // visibility-gated primitive. Pass `poll: { action, seconds, immediate }`. // @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { // @property {boolean} [immediate=false] fire one tick right after mount function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) { return pollWhileVisible( - () => (action ? invoke(action, input) : refresh()), + () => (action ? client.invoke(action, input) : client.refresh()), seconds, { whenVisible, immediate } ); @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) { if (poll) stopPoll = startPoll(poll); return { - invoke, - refresh, + invoke: client.invoke, + refresh: client.refresh, stopPoll: () => stopPoll(), - get state() { return state; }, + get state() { return latestState; }, }; } diff --git a/extensions/wiki-discover/canvas-kit/icons.mjs b/extensions/wiki-discover/canvas-kit/icons.mjs index 9dd30ea..04eb2e9 100644 --- a/extensions/wiki-discover/canvas-kit/icons.mjs +++ b/extensions/wiki-discover/canvas-kit/icons.mjs @@ -23,7 +23,18 @@ const VIEWBOX = "0 0 24 24"; const STROKE_WIDTH = 2; function resolve(name) { - return LUCIDE[name] || LUCIDE[aliases[name]] || null; + // Own-property lookups only. Bracket access on a plain object reaches inherited + // Object.prototype members, so a name like "toString"/"constructor"/ + // "hasOwnProperty" would otherwise resolve to a function (making hasIcon() lie + // and Icon()/lucideSVG() throw in nodeToString). Object.hasOwn keeps resolution + // to the real, vendored icon set. + if (typeof name !== "string") return null; + if (Object.hasOwn(LUCIDE, name)) return LUCIDE[name]; + if (Object.hasOwn(aliases, name)) { + const target = aliases[name]; + if (Object.hasOwn(LUCIDE, target)) return LUCIDE[target]; + } + return null; } function kebab(k) { diff --git a/extensions/wiki-discover/canvas-kit/net.mjs b/extensions/wiki-discover/canvas-kit/net.mjs new file mode 100644 index 0000000..fc1618c --- /dev/null +++ b/extensions/wiki-discover/canvas-kit/net.mjs @@ -0,0 +1,126 @@ +// canvas-kit/net.mjs +// +// Server-side network safety for canvases that fetch external data. A canvas +// action runs on the loopback runtime with the app's network reach, so a +// caller-influenced URL is an SSRF risk: it could target cloud metadata +// (169.254.169.254), loopback, or the private network. This module is the kit's +// sanctioned egress primitive — validate a URL, then fetch it with a hard +// timeout — so every data canvas guards the same way instead of re-inlining the +// check. SERVER-ONLY (uses node:dns / node:net): import it from the SDK-free +// canvas.mjs, never from the browser view. +// +// Defense-in-depth, not a guarantee: a determined attacker could DNS-rebind +// between the resolve check and the connect. For a canvas pointed at a source +// you choose this is adequate; treat any fetched content as untrusted and render +// it as TEXT (never innerHTML). + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * True for addresses a server-side fetch should never reach: loopback, + * link-local (incl. cloud metadata 169.254.169.254), and private/CGNAT ranges. + * @param {string} ip + * @returns {boolean} + */ +export function isBlockedAddress(ip) { + const lower = ip.toLowerCase(); + // Decode an IPv4-mapped/compatible IPv6 literal down to its embedded IPv4 so + // the IPv4 range checks apply. Covers BOTH the dotted tail (::ffff:127.0.0.1) + // and the HEX tail that `new URL()` normalizes literals to (::ffff:7f00:1) — + // without the decode, `[::ffff:127.0.0.1]` reaches loopback and + // `[::ffff:a9fe:a9fe]` reaches cloud metadata. Only matches when the high bits + // are all zero (leading "::"), so a normal public v6 (e.g. 2001:db8::7f00:1) is + // never misread as IPv4. + const addr = embeddedIPv4(lower) ?? ip; + if (isIP(addr) === 4) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private + if (a === 169 && b === 254) return true; // link-local (incl. cloud IMDS) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + return lower === "::1" || lower === "::" || lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd"); +} + +// Decode an IPv4-mapped/compatible IPv6 literal (all-zero high groups, i.e. a +// leading "::", optionally "::ffff:") to its dotted IPv4; else null. Accepts the +// dotted tail (127.0.0.1) and the two-hex-group tail (7f00:1) that URL +// normalization produces. Anchored on "::" so it can't misread a public v6. +function embeddedIPv4(addr) { + const m = addr.match(/^::(?:ffff:)?((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{1,4}:[0-9a-f]{1,4})$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes(".")) return isIP(tail) === 4 ? tail : null; + const [h1, h2] = tail.split(":"); + const hi = parseInt(h1, 16), lo = parseInt(h2, 16); + return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`; +} + +/** + * Allow only http/https to a PUBLIC host. Rejects every address the hostname + * resolves to, so a public name can't be pointed at an internal IP. Throws on a + * blocked/invalid URL; resolves to void when the URL is safe to fetch. + * @param {string} url + */ +export async function assertPublicUrl(url) { + let u; + try { u = new URL(url); } catch { throw new Error("Invalid source URL"); } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Blocked URL protocol: " + u.protocol); + } + let host = u.hostname; + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // IPv6 brackets + if (host.toLowerCase() === "localhost") throw new Error("Blocked host: localhost"); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address); + if (!addrs.length) throw new Error("Could not resolve host: " + host); + for (const ip of addrs) { + if (isBlockedAddress(ip)) throw new Error("Blocked private/loopback address: " + ip); + } +} + +/** + * SSRF-guarded fetch with a mandatory timeout. Runs assertPublicUrl on the URL + * and on EVERY redirect hop, so a chosen public host can't 30x-redirect the + * request into loopback/metadata/private space. Returns the final Response as-is + * (does NOT throw on a non-2xx status — the caller checks res.ok), so it is a + * drop-in for a guarded fetch(). + * + * Redirects are followed MANUALLY (redirect:"manual") rather than by fetch's + * default redirect:"follow": the default would chase a 3xx Location without + * re-running the guard, which silently undoes every check in this module. Here + * each hop's target is re-validated before we connect to it. + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs=12000] abort the WHOLE operation (all hops) after this many ms + * @param {number} [opts.maxRedirects=5] how many 3xx hops to follow before giving up + * @param {object} [opts.headers] request headers (merged as-is) + * @param {RequestInit} [opts.rest] any other fetch init (method, body, …) + * @returns {Promise} + */ +export async function safeFetch(url, { timeoutMs = 12000, maxRedirects = 5, headers, ...rest } = {}) { + // One timeout signal bounds the entire operation, redirects included, so a + // chain of slow hops can't multiply the deadline. + const signal = AbortSignal.timeout(timeoutMs); + let current = url; + for (let hop = 0; ; hop++) { + await assertPublicUrl(current); + const res = await fetch(current, { + ...rest, + headers: headers ?? {}, + redirect: "manual", + signal, + }); + if (res.status >= 300 && res.status < 400 && res.headers.has("location")) { + if (hop >= maxRedirects) throw new Error("Too many redirects"); + const next = new URL(res.headers.get("location"), current).href; + // Discard the redirect body so the connection can be freed before the next hop. + try { await res.body?.cancel(); } catch { /* no body / already consumed */ } + current = next; + continue; + } + return res; + } +} diff --git a/extensions/wiki-discover/canvas-kit/server.mjs b/extensions/wiki-discover/canvas-kit/server.mjs index bf3a07f..589b041 100644 --- a/extensions/wiki-discover/canvas-kit/server.mjs +++ b/extensions/wiki-discover/canvas-kit/server.mjs @@ -20,6 +20,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { validate } from "./validate.mjs"; const KIT_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -53,6 +54,7 @@ export class CanvasKitError extends Error { * @param {(domainId:string)=>any|Promise} [config.loadState] * @param {(domainId:string, state:any)=>void|Promise} [config.saveState] * @param {Record} config.actions + * @param {object} [config.stateSchema] optional JSON-Schema-subset for the durable state; when set, a mutation that violates it is rolled back and fails (500) * @param {string} config.assetsDir absolute path to the canvas web/ folder * @param {(ctx:object,state:any)=>string} [config.statusLine] */ @@ -114,12 +116,37 @@ export function createCanvasRuntime(config) { // Core action invoker — the single code path behind both agent and UI actions. async function invoke(actionName, input, ctx) { - const action = config.actions[actionName]; + // Own-property lookup: `config.actions[actionName]` via bracket access would + // otherwise reach inherited members (e.g. "constructor", "toString"). The + // handler-typeof check below already rejects those, but resolving by + // Object.hasOwn keeps the boundary explicit and consistent with validate.mjs. + const action = + typeof actionName === "string" && Object.hasOwn(config.actions, actionName) + ? config.actions[actionName] + : null; if (!action || typeof action.handler !== "function") { throw new CanvasKitError("unknown_action", `Unknown action: ${actionName}`); } + // Enforce the declared inputSchema at the boundary (agent OR ui). The schema + // is a contract authors already write; validating it here turns "declared but + // unchecked" into a typed boundary and stops a malformed/typo'd payload from + // reaching the handler. A shape violation is the CALLER's fault → invalid_input + // (HTTP 400). Business rules ("title can't be blank") still live in the handler + // and surface as a 500, so a schema-valid-but-empty string reaches the handler. + if (action.inputSchema) { + const errs = validate(action.inputSchema, input ?? {}, "input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid input for '${actionName}': ${errs.join("; ")}`); + } + } const domainId = ctx?.domainId ?? "default"; const d = await getDomain(domainId, ctx); + // Deep snapshot for stateSchema rollback: a handler may mutate state IN PLACE + // and return the same object, so a reference copy (prevState = d.state) would + // point at the same (now-corrupt) object and restore nothing. structuredClone + // gives a real pre-mutation copy. Durable state is JSON-shaped, so it clones + // cleanly. Only pay the clone when a stateSchema is actually configured. + const prevState = config.stateSchema ? structuredClone(d.state) : undefined; let mutated = false; const api = { get state() { return d.state; }, @@ -161,6 +188,17 @@ export function createCanvasRuntime(config) { }; const result = await action.handler(api); if (mutated) { + // Optional stateSchema guards the durable shape: if a handler produced an + // invalid state, roll back the in-memory mutation and fail LOUD (a 500 — + // this is a handler bug, not caller input) instead of persisting/broadcasting + // corrupt state. Absent stateSchema, anything goes (opt-in). + if (config.stateSchema) { + const errs = validate(config.stateSchema, d.state, "state"); + if (errs.length) { + d.state = prevState; // roll back so in-memory stays consistent + throw new Error(`Action '${actionName}' produced invalid state: ${errs.join("; ")}`); + } + } if (config.saveState) await config.saveState(domainId, d.state); broadcast(domainId); } @@ -202,6 +240,12 @@ export function createCanvasRuntime(config) { // local client can't force unbounded memory growth on the loopback runtime. const MAX_BODY_BYTES = 1 << 20; // 1 MiB + // Cap concurrent SSE subscribers PER INSTANCE. A canvas panel needs only one + // /events stream (a couple across reopens); a runaway reconnect loop or a + // hostile local process hitting the loopback port could otherwise accumulate + // unbounded response handles + keep-alive timers. 64 is far above any real use. + const MAX_SSE_CLIENTS = 64; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -240,6 +284,13 @@ export function createCanvasRuntime(config) { // GET /events — Server-Sent Events stream of state if (req.method === "GET" && path === "/events") { + // Refuse once an instance is saturated, so subscribers can't grow without + // bound (each holds a response handle + a keep-alive interval). + if (inst && inst.clients.size >= MAX_SSE_CLIENTS) { + res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "5" }); + res.end("too many event subscribers"); + return; + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -307,6 +358,15 @@ export function createCanvasRuntime(config) { * @returns {Promise<{url:string,title:string,status?:string}>} */ async function openInstance({ instanceId, input, ctx }) { + // Validate the open input against the declared inputSchema (same contract the + // actions get). A bad open payload fails fast with invalid_input rather than + // silently resolving the wrong domain. + if (config.inputSchema) { + const errs = validate(config.inputSchema, input ?? {}, "open input"); + if (errs.length) { + throw new CanvasKitError("invalid_input", `Invalid open input: ${errs.join("; ")}`); + } + } const domainId = config.resolveDomainId ? config.resolveDomainId(input ?? {}, ctx ?? {}) || "default" : "default"; diff --git a/extensions/wiki-discover/canvas-kit/storage.mjs b/extensions/wiki-discover/canvas-kit/storage.mjs index 355983c..3f1177c 100644 --- a/extensions/wiki-discover/canvas-kit/storage.mjs +++ b/extensions/wiki-discover/canvas-kit/storage.mjs @@ -2,18 +2,61 @@ // // Durable JSON state helpers. State is keyed by a *domain id* (a stable logical // identifier resolved from the open input), never by instanceId — per -// create-canvas/SKILL.md. Two scopes: +// create-canvas/SKILL.md. Three tiers, matching the Canvas SDK storage model: // userStore -> $COPILOT_HOME/extensions//artifacts/ (per user, cross-session) -// workspaceStore -> / (per session) +// sessionStore -> $COPILOT_HOME/session-state//extensions// (per session, scratch) +// workspaceStore -> / (rooted at the session workspace) +// +// Sandboxing: callers derive the from a domain id that MUST be sanitized +// to a bare filename (the reference `fileFor` strips everything but +// [A-Za-z0-9._-]); the store never joins caller input as a path, so a domain id +// can't escape its extension's artifacts directory. -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; function copilotHome() { return process.env.COPILOT_HOME || join(homedir(), ".copilot"); } +// Per-file write queue. Concurrent saves to the SAME durable file (an agent +// action and a UI action racing) must not overlap: on Windows two renames to the +// same destination collide with EPERM even with unique temp names. Chaining each +// save onto the previous one for that path serializes them (last enqueued wins), +// which is both correct (no interleaved writes) and portable. +const writeQueues = new Map(); // absolute file path -> tail Promise + +async function atomicWrite(file, data) { + await mkdir(dirname(file), { recursive: true }); + // Write to a temp sibling then atomically rename into place, so a crash or + // interruption mid-write can never truncate the existing durable file. + // rename(2) is atomic on the same filesystem. The temp name mixes pid + time + + // RANDOM bytes so two writers can never pick the same temp path. + const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + try { + await rename(tmp, file); + } catch (err) { + // Windows can transiently EPERM/EACCES a rename when the destination is + // briefly held (AV scanner, indexer). Retry once after a short beat; always + // clean up our temp so a failed save leaves no orphaned *.tmp behind. + if (err?.code === "EPERM" || err?.code === "EACCES") { + await new Promise((r) => setTimeout(r, 25)); + try { + await rename(tmp, file); + } catch (err2) { + await unlink(tmp).catch(() => {}); + throw err2; + } + } else { + await unlink(tmp).catch(() => {}); + throw err; + } + } +} + function makeStore(file) { return { file, @@ -32,13 +75,15 @@ function makeStore(file) { } }, async save(data) { - await mkdir(dirname(file), { recursive: true }); - // Write to a temp sibling then atomically rename into place, so a crash or - // interruption mid-write can never truncate the existing durable file. - // rename(2) is atomic on the same filesystem. - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await rename(tmp, file); + // Serialize behind any in-flight save for this exact path (see writeQueues). + const prev = writeQueues.get(file) ?? Promise.resolve(); + const run = prev.catch(() => {}).then(() => atomicWrite(file, data)); + writeQueues.set(file, run); + try { + await run; + } finally { + if (writeQueues.get(file) === run) writeQueues.delete(file); + } }, }; } @@ -48,6 +93,24 @@ export function userStore(extensionName, fileName) { return makeStore(join(copilotHome(), "extensions", extensionName, "artifacts", fileName)); } +/** + * Per-session scratch store for a named extension, rooted under the session's + * state directory. Use for state that should NOT outlive the session (drafts, + * one-off working data). Pass the session id (e.g. from the SDK ctx). + */ +export function sessionStore(sessionId, extensionName, fileName) { + // Reduce the session id to a single safe path segment. The charset filter keeps + // "." (so dotted ids survive), so we must ALSO collapse any ".." run — otherwise + // a session id of ".." would join to one level ABOVE session-state and escape the + // per-session root. (sessionId is normally a trusted SDK value; this keeps the + // sanitizer honest regardless.) + const safeSession = + String(sessionId) + .replace(/[^A-Za-z0-9._-]/g, "_") + .replace(/\.\.+/g, "_") || "default"; + return makeStore(join(copilotHome(), "session-state", safeSession, "extensions", extensionName, fileName)); +} + /** Per-session store rooted at the session workspace path. */ export function workspaceStore(workspacePath, fileName) { return makeStore(join(workspacePath, fileName)); diff --git a/extensions/wiki-discover/canvas-kit/validate.mjs b/extensions/wiki-discover/canvas-kit/validate.mjs new file mode 100644 index 0000000..d44925e --- /dev/null +++ b/extensions/wiki-discover/canvas-kit/validate.mjs @@ -0,0 +1,146 @@ +// canvas-kit/validate.mjs +// +// A tiny, dependency-free JSON-Schema-*subset* validator. It exists so the +// runtime can ENFORCE the action `inputSchema` (and an optional `stateSchema`) +// that authors already declare — turning the iframe↔extension / agent↔extension +// contract from "declared but unchecked" into "validated at the boundary". No +// npm dependency (the kit ships vendored, no install step), and it only covers +// the JSON Schema features the kit's schemas actually use: +// +// type "object" | "array" | "string" | "number" | "integer" | +// "boolean" | "null" (or an array of those) +// properties per-key subschemas (objects) +// required array of required property names (objects) +// additionalProperties false | subschema (objects) +// items subschema for every element (arrays) +// enum allowed literal values (deep-equal) +// minLength/maxLength string length bounds +// minimum/maximum numeric bounds +// minItems/maxItems array length bounds +// +// It is intentionally forgiving: an absent/empty schema validates anything, and +// unknown keywords are ignored (so a richer schema never hard-fails here). The +// goal is to catch the shape mistakes that actually break canvases — wrong type, +// missing required field, unknown/typo'd property, out-of-enum value — not to be +// a complete JSON Schema implementation. + +function typeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; // "string" | "number" | "boolean" | "object" | "undefined" +} + +// Does `value` satisfy a single JSON Schema `type` token? "number" accepts +// integers; "integer" requires a whole number. +function matchesType(value, t) { + const actual = typeOf(value); + if (t === "number") return actual === "number" || actual === "integer"; + if (t === "integer") return actual === "integer"; + return actual === t; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a), bk = Object.keys(b); + return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** + * Validate `value` against a JSON-Schema-subset `schema`. + * @param {object|undefined} schema + * @param {any} value + * @param {string} [path] dotted path used in error messages (default "input") + * @returns {string[]} human-readable error messages; empty array = valid. + */ +export function validate(schema, value, path = "input") { + const errors = []; + walk(schema, value, path, errors); + return errors; +} + +function walk(schema, value, path, errors) { + if (!schema || typeof schema !== "object") return; // absent/degenerate schema = anything goes + + // type (single token or array of tokens) + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((t) => matchesType(value, t))) { + errors.push(`${path}: expected ${types.join(" | ")}, got ${typeOf(value)}`); + return; // a wrong base type makes deeper checks meaningless + } + } + + // enum + if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) { + errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`); + } + + const kind = typeOf(value); + + if (kind === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: must be at most ${schema.maxLength} characters`); + } + } + + if (kind === "number" || kind === "integer") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: must be <= ${schema.maximum}`); + } + } + + if (kind === "array") { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path}: must have at least ${schema.minItems} item(s)`); + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + errors.push(`${path}: must have at most ${schema.maxItems} item(s)`); + } + if (schema.items) { + value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors)); + } + } + + if (kind === "object") { + const props = schema.properties ?? {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + // Object.hasOwn (not `value[key] === undefined`): a required property + // named after a prototype member (e.g. "toString", "constructor") would + // otherwise read the inherited value and wrongly pass the check. + if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required`); + } + } + for (const [key, sub] of Object.entries(props)) { + if (Object.hasOwn(value, key)) walk(sub, value[key], `${path}.${key}`, errors); + } + // Membership is tested with Object.hasOwn, NOT `key in props`: `in` walks the + // prototype chain, so an extra key named "toString"/"constructor"/"__proto__" + // etc. would satisfy `key in props` and escape additionalProperties. This + // validator is the enforcement boundary (input → 400, state → 500), so that + // would be a real contract hole. + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) errors.push(`${path}.${key}: unexpected property`); + } + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(props, key)) walk(schema.additionalProperties, value[key], `${path}.${key}`, errors); + } + } + } +} diff --git a/extensions/wiki-discover/canvas-kit/version.mjs b/extensions/wiki-discover/canvas-kit/version.mjs index 93471a9..bf832c3 100644 --- a/extensions/wiki-discover/canvas-kit/version.mjs +++ b/extensions/wiki-discover/canvas-kit/version.mjs @@ -10,4 +10,4 @@ // convention (the skill ships as files, not an npm version); a commit short-sha // works too. Re-exported from client.mjs so a canvas can read it at runtime. -export const KIT_VERSION = "2026-07-05.1"; +export const KIT_VERSION = "2026-07-07.4"; diff --git a/extensions/wiki-discover/test/smoke.test.mjs b/extensions/wiki-discover/test/smoke.test.mjs index f04fac7..152c2ea 100644 --- a/extensions/wiki-discover/test/smoke.test.mjs +++ b/extensions/wiki-discover/test/smoke.test.mjs @@ -243,7 +243,10 @@ try { assert.equal((await st("flow")).queue.length, 1, "queue must not be cleared on a no-op removal"); }); await test("rating the current article advances + re-ranks + marks it seen (M1)", async () => { - await inv("rate", { value: "down", article: undefined }, "flow"); // rate current (Galaxy) + // Omit `article` to rate the current card. (A real client drops undefined keys + // during JSON serialization; the kit's schema validation type-checks a + // present-but-undefined `article`, so pass nothing rather than `article: undefined`.) + await inv("rate", { value: "down" }, "flow"); // rate current (Galaxy) const s = await st("flow"); assert.equal(s.current.title, "Cooking", "advanced to the next ranked card"); assert.ok(s.seenIds.includes("1"), "the rated article id is now seen");