fix(skills): prevent skills loading crash
This commit is contained in:
@@ -35,6 +35,16 @@ ${body ?? `# ${name}\n`}
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("buildWorkspaceSkillsPrompt", () => {
|
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 () => {
|
it("loads skills from workspace skills/", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
const skillDir = path.join(workspaceDir, "skills", "demo-skill");
|
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", () => {
|
describe("applySkillEnvOverrides", () => {
|
||||||
it("sets and restores env vars", async () => {
|
it("sets and restores env vars", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
formatSkillsForPrompt,
|
formatSkillsForPrompt,
|
||||||
loadSkillsFromDir,
|
loadSkillsFromDir,
|
||||||
type Skill,
|
type Skill,
|
||||||
type SkillFrontmatter,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import type { ClawdisConfig, SkillConfig } from "../config/config.js";
|
import type { ClawdisConfig, SkillConfig } from "../config/config.js";
|
||||||
@@ -22,9 +21,11 @@ type ClawdisSkillMetadata = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ParsedSkillFrontmatter = Record<string, string>;
|
||||||
|
|
||||||
type SkillEntry = {
|
type SkillEntry = {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
frontmatter: SkillFrontmatter;
|
frontmatter: ParsedSkillFrontmatter;
|
||||||
clawdis?: ClawdisSkillMetadata;
|
clawdis?: ClawdisSkillMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export type SkillSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getFrontmatterValue(
|
function getFrontmatterValue(
|
||||||
frontmatter: SkillFrontmatter,
|
frontmatter: ParsedSkillFrontmatter,
|
||||||
key: string,
|
key: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const raw = frontmatter[key];
|
const raw = frontmatter[key];
|
||||||
@@ -51,8 +52,8 @@ function stripQuotes(value: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFrontmatter(content: string): SkillFrontmatter {
|
function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||||
const frontmatter: SkillFrontmatter = {};
|
const frontmatter: ParsedSkillFrontmatter = {};
|
||||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
if (!normalized.startsWith("---")) return frontmatter;
|
if (!normalized.startsWith("---")) return frontmatter;
|
||||||
const endIndex = normalized.indexOf("\n---", 3);
|
const endIndex = normalized.indexOf("\n---", 3);
|
||||||
@@ -143,7 +144,7 @@ function hasBinary(bin: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveClawdisMetadata(
|
function resolveClawdisMetadata(
|
||||||
frontmatter: SkillFrontmatter,
|
frontmatter: ParsedSkillFrontmatter,
|
||||||
): ClawdisSkillMetadata | undefined {
|
): ClawdisSkillMetadata | undefined {
|
||||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@@ -324,11 +325,11 @@ function loadSkillEntries(
|
|||||||
const managedSkills = loadSkillsFromDir({
|
const managedSkills = loadSkillsFromDir({
|
||||||
dir: managedSkillsDir,
|
dir: managedSkillsDir,
|
||||||
source: "clawdis-managed",
|
source: "clawdis-managed",
|
||||||
}).skills;
|
});
|
||||||
const workspaceSkills = loadSkillsFromDir({
|
const workspaceSkills = loadSkillsFromDir({
|
||||||
dir: workspaceSkillsDir,
|
dir: workspaceSkillsDir,
|
||||||
source: "clawdis-workspace",
|
source: "clawdis-workspace",
|
||||||
}).skills;
|
});
|
||||||
|
|
||||||
const merged = new Map<string, Skill>();
|
const merged = new Map<string, Skill>();
|
||||||
for (const skill of managedSkills) merged.set(skill.name, skill);
|
for (const skill of managedSkills) merged.set(skill.name, skill);
|
||||||
@@ -336,7 +337,7 @@ function loadSkillEntries(
|
|||||||
|
|
||||||
const skillEntries: SkillEntry[] = Array.from(merged.values()).map(
|
const skillEntries: SkillEntry[] = Array.from(merged.values()).map(
|
||||||
(skill) => {
|
(skill) => {
|
||||||
let frontmatter: SkillFrontmatter = {};
|
let frontmatter: ParsedSkillFrontmatter = {};
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
||||||
frontmatter = parseFrontmatter(raw);
|
frontmatter = parseFrontmatter(raw);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import http, { type Server } from "node:http";
|
import http, { type Server } from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import chokidar from "chokidar";
|
import chokidar from "chokidar";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
@@ -25,6 +26,36 @@ export type CanvasHostServer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const WS_PATH = "/__clawdis/ws";
|
const WS_PATH = "/__clawdis/ws";
|
||||||
|
const A2UI_PATH = "/__clawdis__/a2ui";
|
||||||
|
|
||||||
|
async function resolveA2uiRoot(): Promise<string | null> {
|
||||||
|
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 {
|
export function injectCanvasLiveReload(html: string): string {
|
||||||
const snippet = `
|
const snippet = `
|
||||||
@@ -237,6 +268,8 @@ export async function startCanvasHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bindHost = opts.listenHost?.trim() || "0.0.0.0";
|
const bindHost = opts.listenHost?.trim() || "0.0.0.0";
|
||||||
|
const a2uiRoot = await resolveA2uiRoot();
|
||||||
|
const a2uiRootReal = a2uiRoot ? await fs.realpath(a2uiRoot) : null;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
@@ -249,6 +282,40 @@ export async function startCanvasHost(
|
|||||||
return;
|
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);
|
const filePath = await resolveFilePath(rootReal, url.pathname);
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
if (url.pathname === "/" || url.pathname.endsWith("/")) {
|
if (url.pathname === "/" || url.pathname.endsWith("/")) {
|
||||||
|
|||||||
@@ -359,11 +359,12 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
skills: z
|
skills: z
|
||||||
.record(
|
.record(
|
||||||
|
z.string(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
env: z.record(z.string()).optional(),
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.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(
|
export function parseConfigJson5(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|||||||
import {
|
import {
|
||||||
createServer as createHttpServer,
|
createServer as createHttpServer,
|
||||||
type Server as HttpServer,
|
type Server as HttpServer,
|
||||||
|
type IncomingMessage,
|
||||||
} from "node:http";
|
} from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -325,6 +326,38 @@ function buildSnapshot(): Snapshot {
|
|||||||
|
|
||||||
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||||
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
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 MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||||
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||||
const TICK_INTERVAL_MS = 30_000;
|
const TICK_INTERVAL_MS = 30_000;
|
||||||
@@ -1896,6 +1929,7 @@ export async function startGatewayServer(
|
|||||||
host: bridgeHost,
|
host: bridgeHost,
|
||||||
port: bridgePort,
|
port: bridgePort,
|
||||||
serverName: machineDisplayName,
|
serverName: machineDisplayName,
|
||||||
|
canvasHostPort: canvasHost?.port,
|
||||||
onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req),
|
onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req),
|
||||||
onAuthenticated: async (node) => {
|
onAuthenticated: async (node) => {
|
||||||
const host = node.displayName?.trim() || node.nodeId;
|
const host = node.displayName?.trim() || node.nodeId;
|
||||||
@@ -2181,13 +2215,14 @@ export async function startGatewayServer(
|
|||||||
.start()
|
.start()
|
||||||
.catch((err) => logError(`cron failed to start: ${String(err)}`));
|
.catch((err) => logError(`cron failed to start: ${String(err)}`));
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket, req) => {
|
||||||
let client: Client | null = null;
|
let client: Client | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const connId = randomUUID();
|
const connId = randomUUID();
|
||||||
const remoteAddr = (
|
const remoteAddr = (
|
||||||
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
||||||
)._socket?.remoteAddress;
|
)._socket?.remoteAddress;
|
||||||
|
const canvasHostUrl = deriveCanvasHostUrl(req, canvasHost?.port);
|
||||||
logWs("in", "open", { connId, remoteAddr });
|
logWs("in", "open", { connId, remoteAddr });
|
||||||
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
|
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
|
||||||
params?.client?.mode === "webchat" ||
|
params?.client?.mode === "webchat" ||
|
||||||
@@ -2399,6 +2434,7 @@ export async function startGatewayServer(
|
|||||||
},
|
},
|
||||||
features: { methods: METHODS, events: EVENTS },
|
features: { methods: METHODS, events: EVENTS },
|
||||||
snapshot,
|
snapshot,
|
||||||
|
canvasHostUrl,
|
||||||
policy: {
|
policy: {
|
||||||
maxPayload: MAX_PAYLOAD_BYTES,
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||||
|
|||||||
Reference in New Issue
Block a user