From 0e2993a6c8aa67d8f3c306c75d9c3973eb04d41f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 11:49:24 +0000 Subject: [PATCH] fix(skills): prevent skills loading crash --- src/agents/skills.test.ts | 23 ++++++++++++++ src/agents/skills.ts | 19 +++++------ src/canvas-host/server.ts | 67 +++++++++++++++++++++++++++++++++++++++ src/config/config.ts | 8 +++-- src/gateway/server.ts | 38 +++++++++++++++++++++- 5 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 7b372d89f..0729d4beb 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -35,6 +35,16 @@ ${body ?? `# ${name}\n`} } describe("buildWorkspaceSkillsPrompt", () => { + it("returns empty prompt when skills dirs are missing", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + + expect(prompt).toBe(""); + }); + it("loads skills from workspace skills/", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const skillDir = path.join(workspaceDir, "skills", "demo-skill"); @@ -196,6 +206,19 @@ describe("buildWorkspaceSkillsPrompt", () => { }); }); +describe("buildWorkspaceSkillSnapshot", () => { + it("returns an empty snapshot when skills dirs are missing", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + + const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + + expect(snapshot.prompt).toBe(""); + expect(snapshot.skills).toEqual([]); + }); +}); + describe("applySkillEnvOverrides", () => { it("sets and restores env vars", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 19ce6245e..8fc8325ae 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -5,7 +5,6 @@ import { formatSkillsForPrompt, loadSkillsFromDir, type Skill, - type SkillFrontmatter, } from "@mariozechner/pi-coding-agent"; import type { ClawdisConfig, SkillConfig } from "../config/config.js"; @@ -22,9 +21,11 @@ type ClawdisSkillMetadata = { }; }; +type ParsedSkillFrontmatter = Record; + type SkillEntry = { skill: Skill; - frontmatter: SkillFrontmatter; + frontmatter: ParsedSkillFrontmatter; clawdis?: ClawdisSkillMetadata; }; @@ -34,7 +35,7 @@ export type SkillSnapshot = { }; function getFrontmatterValue( - frontmatter: SkillFrontmatter, + frontmatter: ParsedSkillFrontmatter, key: string, ): string | undefined { const raw = frontmatter[key]; @@ -51,8 +52,8 @@ function stripQuotes(value: string): string { return value; } -function parseFrontmatter(content: string): SkillFrontmatter { - const frontmatter: SkillFrontmatter = {}; +function parseFrontmatter(content: string): ParsedSkillFrontmatter { + const frontmatter: ParsedSkillFrontmatter = {}; const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (!normalized.startsWith("---")) return frontmatter; const endIndex = normalized.indexOf("\n---", 3); @@ -143,7 +144,7 @@ function hasBinary(bin: string): boolean { } function resolveClawdisMetadata( - frontmatter: SkillFrontmatter, + frontmatter: ParsedSkillFrontmatter, ): ClawdisSkillMetadata | undefined { const raw = getFrontmatterValue(frontmatter, "metadata"); if (!raw) return undefined; @@ -324,11 +325,11 @@ function loadSkillEntries( const managedSkills = loadSkillsFromDir({ dir: managedSkillsDir, source: "clawdis-managed", - }).skills; + }); const workspaceSkills = loadSkillsFromDir({ dir: workspaceSkillsDir, source: "clawdis-workspace", - }).skills; + }); const merged = new Map(); for (const skill of managedSkills) merged.set(skill.name, skill); @@ -336,7 +337,7 @@ function loadSkillEntries( const skillEntries: SkillEntry[] = Array.from(merged.values()).map( (skill) => { - let frontmatter: SkillFrontmatter = {}; + let frontmatter: ParsedSkillFrontmatter = {}; try { const raw = fs.readFileSync(skill.filePath, "utf-8"); frontmatter = parseFrontmatter(raw); diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 69994be21..b3d868a49 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import http, { type Server } from "node:http"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import chokidar from "chokidar"; import express from "express"; @@ -25,6 +26,36 @@ export type CanvasHostServer = { }; const WS_PATH = "/__clawdis/ws"; +const A2UI_PATH = "/__clawdis__/a2ui"; + +async function resolveA2uiRoot(): Promise { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + // Running from source (tsx) or dist (tsc + copied assets). + path.resolve(here, "a2ui"), + // Running from dist without copied assets (fallback to source). + path.resolve(here, "../../src/canvas-host/a2ui"), + // Running from repo root. + path.resolve(process.cwd(), "src/canvas-host/a2ui"), + path.resolve(process.cwd(), "dist/canvas-host/a2ui"), + ]; + if (process.execPath) { + candidates.unshift(path.resolve(path.dirname(process.execPath), "a2ui")); + } + + for (const dir of candidates) { + try { + const indexPath = path.join(dir, "index.html"); + const bundlePath = path.join(dir, "a2ui.bundle.js"); + await fs.stat(indexPath); + await fs.stat(bundlePath); + return dir; + } catch { + // try next + } + } + return null; +} export function injectCanvasLiveReload(html: string): string { const snippet = ` @@ -237,6 +268,8 @@ export async function startCanvasHost( } const bindHost = opts.listenHost?.trim() || "0.0.0.0"; + const a2uiRoot = await resolveA2uiRoot(); + const a2uiRootReal = a2uiRoot ? await fs.realpath(a2uiRoot) : null; const app = express(); app.disable("x-powered-by"); @@ -249,6 +282,40 @@ export async function startCanvasHost( return; } + if ( + url.pathname === A2UI_PATH || + url.pathname.startsWith(`${A2UI_PATH}/`) + ) { + if (!a2uiRootReal) { + res + .status(503) + .type("text/plain; charset=utf-8") + .send("A2UI assets not found"); + return; + } + const rel = url.pathname.slice(A2UI_PATH.length); + const filePath = await resolveFilePath(a2uiRootReal, rel || "/"); + if (!filePath) { + res.status(404).send("not found"); + return; + } + const lower = filePath.toLowerCase(); + const mime = + lower.endsWith(".html") || lower.endsWith(".htm") + ? "text/html" + : (detectMime({ filePath }) ?? "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); + if (mime === "text/html") { + const html = await fs.readFile(filePath, "utf8"); + res + .type("text/html; charset=utf-8") + .send(injectCanvasLiveReload(html)); + return; + } + res.type(mime).send(await fs.readFile(filePath)); + return; + } + const filePath = await resolveFilePath(rootReal, url.pathname); if (!filePath) { if (url.pathname === "/" || url.pathname.endsWith("/")) { diff --git a/src/config/config.ts b/src/config/config.ts index 7d577bee3..2804d07d5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -359,11 +359,12 @@ const ClawdisSchema = z.object({ .optional(), skills: z .record( + z.string(), z .object({ enabled: z.boolean().optional(), apiKey: z.string().optional(), - env: z.record(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), }) .passthrough(), ) @@ -459,7 +460,10 @@ export function validateConfigObject( })), }; } - return { ok: true, config: applyIdentityDefaults(validated.data) }; + return { + ok: true, + config: applyIdentityDefaults(validated.data as ClawdisConfig), + }; } export function parseConfigJson5( diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bbd2ccbde..5fdfaef87 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { createServer as createHttpServer, type Server as HttpServer, + type IncomingMessage, } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -325,6 +326,38 @@ function buildSnapshot(): Snapshot { const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit + +function deriveCanvasHostUrl( + req: IncomingMessage | undefined, + canvasPort: number | undefined, +) { + if (!req || !canvasPort) return undefined; + const hostHeader = req.headers.host?.trim(); + const forwardedProto = + typeof req.headers["x-forwarded-proto"] === "string" + ? req.headers["x-forwarded-proto"] + : Array.isArray(req.headers["x-forwarded-proto"]) + ? req.headers["x-forwarded-proto"][0] + : undefined; + const scheme = forwardedProto === "https" ? "https" : "http"; + + let host = ""; + if (hostHeader) { + try { + const parsed = new URL(`http://${hostHeader}`); + host = parsed.hostname; + } catch { + host = ""; + } + } + if (!host) { + host = req.socket?.localAddress?.trim() ?? ""; + } + if (!host) return undefined; + + const formattedHost = host.includes(":") ? `[${host}]` : host; + return `${scheme}://${formattedHost}:${canvasPort}`; +} const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits const HANDSHAKE_TIMEOUT_MS = 10_000; const TICK_INTERVAL_MS = 30_000; @@ -1896,6 +1929,7 @@ export async function startGatewayServer( host: bridgeHost, port: bridgePort, serverName: machineDisplayName, + canvasHostPort: canvasHost?.port, onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), onAuthenticated: async (node) => { const host = node.displayName?.trim() || node.nodeId; @@ -2181,13 +2215,14 @@ export async function startGatewayServer( .start() .catch((err) => logError(`cron failed to start: ${String(err)}`)); - wss.on("connection", (socket) => { + wss.on("connection", (socket, req) => { let client: Client | null = null; let closed = false; const connId = randomUUID(); const remoteAddr = ( socket as WebSocket & { _socket?: { remoteAddress?: string } } )._socket?.remoteAddress; + const canvasHostUrl = deriveCanvasHostUrl(req, canvasHost?.port); logWs("in", "open", { connId, remoteAddr }); const isWebchatConnect = (params: ConnectParams | null | undefined) => params?.client?.mode === "webchat" || @@ -2399,6 +2434,7 @@ export async function startGatewayServer( }, features: { methods: METHODS, events: EVENTS }, snapshot, + canvasHostUrl, policy: { maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES,