@netnodeag/kraftwerk 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/routines.js +2 -3
- package/dist/client/index.d.ts +59 -0
- package/dist/client/index.js +63 -0
- package/dist/client/live.d.ts +34 -0
- package/dist/client/live.js +169 -0
- package/dist/client/routes.d.ts +125 -0
- package/dist/client/routes.js +126 -0
- package/dist/inspector/api/agents.d.ts +31 -0
- package/dist/inspector/api/agents.js +50 -0
- package/dist/inspector/api/attention.d.ts +10 -0
- package/dist/inspector/api/attention.js +23 -0
- package/dist/inspector/api/channels.d.ts +67 -0
- package/dist/inspector/api/channels.js +58 -0
- package/dist/inspector/api/chats.d.ts +55 -0
- package/dist/inspector/api/chats.js +154 -0
- package/dist/inspector/api/files.d.ts +15 -0
- package/dist/inspector/api/files.js +43 -0
- package/dist/inspector/api/git.d.ts +25 -0
- package/dist/inspector/api/git.js +56 -0
- package/dist/inspector/api/index.d.ts +334 -0
- package/dist/inspector/api/index.js +43 -0
- package/dist/inspector/api/knowledge.d.ts +13 -0
- package/dist/inspector/api/knowledge.js +56 -0
- package/dist/inspector/api/mime.d.ts +2 -0
- package/dist/inspector/api/mime.js +18 -0
- package/dist/inspector/api/projects.d.ts +11 -0
- package/dist/inspector/api/projects.js +32 -0
- package/dist/inspector/api/router.d.ts +75 -0
- package/dist/inspector/api/router.js +186 -0
- package/dist/inspector/api/runs.d.ts +33 -0
- package/dist/inspector/api/runs.js +105 -0
- package/dist/inspector/api/socket.d.ts +3 -0
- package/dist/inspector/api/socket.js +115 -0
- package/dist/inspector/api/vibeables.d.ts +4 -0
- package/dist/inspector/api/vibeables.js +31 -0
- package/dist/inspector/api/workspace.d.ts +55 -0
- package/dist/inspector/api/workspace.js +117 -0
- package/dist/inspector/chat/sessions.js +31 -19
- package/dist/inspector/context.d.ts +5 -7
- package/dist/inspector/context.js +6 -20
- package/dist/inspector/git.js +52 -43
- package/dist/inspector/live.d.ts +42 -0
- package/dist/inspector/live.js +91 -0
- package/dist/inspector/notifications.js +19 -13
- package/dist/inspector/routines.d.ts +1 -1
- package/dist/inspector/routines.js +8 -5
- package/dist/inspector/runner.js +5 -4
- package/dist/inspector/server.d.ts +2 -2
- package/dist/inspector/server.js +59 -1393
- package/dist/inspector/version.d.ts +9 -0
- package/dist/inspector/version.js +27 -0
- package/dist/inspector/vibeables.js +25 -23
- package/dist/inspector/workspace.d.ts +42 -0
- package/dist/inspector/workspace.js +63 -0
- package/dist/inspector/ws.d.ts +11 -0
- package/dist/inspector/ws.js +143 -0
- package/inspector/dist/assets/{dist-COwx246i.js → dist-9RESajfo.js} +1 -1
- package/inspector/dist/assets/{dist-OMwGczSn.js → dist-Bm5S1BoZ.js} +1 -1
- package/inspector/dist/assets/dist-C8BofvHx.js +1 -0
- package/inspector/dist/assets/{dist-jBYydzOe.js → dist-CIs4wnTu.js} +1 -1
- package/inspector/dist/assets/{dist-Crdu4xR8.js → dist-CjuETd-_.js} +1 -1
- package/inspector/dist/assets/{dist-ClyjyU-C.js → dist-CmVK6rgN.js} +1 -1
- package/inspector/dist/assets/{dist-CeGII4MF.js → dist-CryXvMV_.js} +1 -1
- package/inspector/dist/assets/{dist-DqU5Wgif.js → dist-D1hk0iC5.js} +1 -1
- package/inspector/dist/assets/{dist-BIHxko9t.js → dist-DvW9QE1d.js} +1 -1
- package/inspector/dist/assets/{dist-BMBdyFA5.js → dist-VD9jMxBv.js} +1 -1
- package/inspector/dist/assets/{dist-BnnTidDP.js → dist-VlAqWD8T.js} +1 -1
- package/inspector/dist/assets/{editor-Cmri6xKM.js → editor-DkWqwl89.js} +3 -3
- package/inspector/dist/assets/index-DRJS2pGj.js +229 -0
- package/inspector/dist/index.html +1 -1
- package/package.json +7 -2
- package/inspector/dist/assets/dist-wc-PzIzB.js +0 -1
- package/inspector/dist/assets/index-C1s81t9X.js +0 -229
package/dist/inspector/server.js
CHANGED
|
@@ -1,100 +1,22 @@
|
|
|
1
1
|
import { existsSync, promises as fs } from "node:fs";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { publicHostFor, publicUrlFor, resolveProject, tunnelFor } from "../config.js";
|
|
4
|
+
import { setDefaultWorkspace, Workspace } from "./workspace.js";
|
|
5
|
+
import { publicHostFor, resolveProject, tunnelFor } from "../config.js";
|
|
7
6
|
import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
|
|
8
|
-
import { cloudGoodbye,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
|
|
22
|
-
import { cancelChat, createChat, forkChat, resetSession, answerElicitation, steerChat, stopChatTask, setChatConfig, listAgentSessionsFor, deleteChat, renameChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
|
|
23
|
-
import { bundleDetail, conceptDetail, createBundle, deleteBundle, knowledgeIndex, UI_ACTOR, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
24
|
-
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
25
|
-
import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
|
|
26
|
-
import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, markWorkspaceStopped, registerInstance, registerWorkspace, startWorkspace, stopWorkspace, tildify, unregisterInstance, } from "./instances.js";
|
|
27
|
-
import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
|
|
28
|
-
import { getSettings, saveSettings } from "./settings.js";
|
|
29
|
-
import { addRepo, listRepos, openRepos, removeRepo, repoCommitDiff, repoDetail, repoDiff, updateRepo, } from "./repos.js";
|
|
30
|
-
import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
|
|
31
|
-
import { searchAgents } from "./search.js";
|
|
32
|
-
import { appendProjectLog, createProject, deleteProject, saveLayout, getProject, linkProject, listProjects, openProjects, saveProject, } from "./projects.js";
|
|
33
|
-
import { allRoutines, deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
|
|
34
|
-
/**
|
|
35
|
-
* The inspector server: a plain node:http server with no dependencies.
|
|
36
|
-
* Serves the prebuilt SPA (inspector/dist) plus the JSON/file API the
|
|
37
|
-
* frontend polls. Realtime is polling — no daemon, no socket, works on a
|
|
38
|
-
* plain filesystem.
|
|
39
|
-
*/
|
|
40
|
-
/** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
|
|
41
|
-
const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
|
|
42
|
-
const MIME = {
|
|
43
|
-
".html": "text/html; charset=utf-8",
|
|
44
|
-
".js": "text/javascript; charset=utf-8",
|
|
45
|
-
".css": "text/css; charset=utf-8",
|
|
46
|
-
".svg": "image/svg+xml",
|
|
47
|
-
".png": "image/png",
|
|
48
|
-
".jpg": "image/jpeg",
|
|
49
|
-
".jpeg": "image/jpeg",
|
|
50
|
-
".gif": "image/gif",
|
|
51
|
-
".webp": "image/webp",
|
|
52
|
-
".ico": "image/x-icon",
|
|
53
|
-
".pdf": "application/pdf",
|
|
54
|
-
".json": "application/json; charset=utf-8",
|
|
55
|
-
".woff": "font/woff",
|
|
56
|
-
".woff2": "font/woff2",
|
|
57
|
-
".txt": "text/plain; charset=utf-8",
|
|
58
|
-
};
|
|
59
|
-
/** Text preview payloads are capped; the tail matters most for logs. */
|
|
60
|
-
const MAX_TEXT = 400_000;
|
|
61
|
-
function json(res, body, status = 200) {
|
|
62
|
-
// A late error on an SSE response must not try to write headers again.
|
|
63
|
-
if (res.headersSent)
|
|
64
|
-
return void res.end();
|
|
65
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
66
|
-
res.end(JSON.stringify(body));
|
|
67
|
-
}
|
|
68
|
-
/** A raw upload body (attachments), capped at `max` bytes. */
|
|
69
|
-
function readRawBody(req, max) {
|
|
70
|
-
return new Promise((resolve, reject) => {
|
|
71
|
-
const chunks = [];
|
|
72
|
-
let size = 0;
|
|
73
|
-
req.on("data", (c) => {
|
|
74
|
-
size += c.length;
|
|
75
|
-
if (size > max) {
|
|
76
|
-
reject(new Error(`file too large (max ${Math.round(max / 1_000_000)} MB)`));
|
|
77
|
-
req.destroy();
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
chunks.push(c);
|
|
81
|
-
});
|
|
82
|
-
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
83
|
-
req.on("error", reject);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
function readBody(req) {
|
|
87
|
-
return new Promise((resolve, reject) => {
|
|
88
|
-
let data = "";
|
|
89
|
-
req.on("data", (c) => {
|
|
90
|
-
data += c;
|
|
91
|
-
if (data.length > 1_000_000)
|
|
92
|
-
reject(new Error("body too large"));
|
|
93
|
-
});
|
|
94
|
-
req.on("end", () => resolve(data));
|
|
95
|
-
req.on("error", reject);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
7
|
+
import { cloudGoodbye, startCloudSync, stopCloudSync } from "./cloud.js";
|
|
8
|
+
import { disposeAllBackends } from "./chat/sessions.js";
|
|
9
|
+
import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "./instances.js";
|
|
10
|
+
import { startGitSync } from "./git.js";
|
|
11
|
+
import { disposeAllDevs, proxyUpgrade, serveVibeable } from "./vibeables.js";
|
|
12
|
+
import { startRoutineScheduler } from "./routines.js";
|
|
13
|
+
import { apiRouter } from "./api/index.js";
|
|
14
|
+
import { serveSocket } from "./api/socket.js";
|
|
15
|
+
import { acceptWebSocket } from "./ws.js";
|
|
16
|
+
import { json } from "./api/router.js";
|
|
17
|
+
import { MIME } from "./api/mime.js";
|
|
18
|
+
import { getPkgVersion, RESTART_EXIT_CODE } from "./version.js";
|
|
19
|
+
export { RESTART_EXIT_CODE };
|
|
98
20
|
/**
|
|
99
21
|
* Interface to bind. Loopback by default: the UI is unauthenticated and its
|
|
100
22
|
* chat runs coding agents against the repo, so it must not appear on the LAN
|
|
@@ -108,16 +30,6 @@ const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv
|
|
|
108
30
|
const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
|
|
109
31
|
const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
110
32
|
const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
|
|
111
|
-
/**
|
|
112
|
-
* The hostname from kraftwerk.yml `public`, when set: the name a tunnel or
|
|
113
|
-
* reverse proxy delivers as Host. A Cloudflare Tunnel forwards the
|
|
114
|
-
* browser's Host untouched and sets no X-Forwarded-Host, so without this
|
|
115
|
-
* the loopback bind would refuse every request that came through it. A
|
|
116
|
-
* rebinding page cannot exploit it: the name is one the operator owns.
|
|
117
|
-
*/
|
|
118
|
-
let publicHost = "";
|
|
119
|
-
/** Access verification for requests arriving via the public hostname (kraftwerk.yml `tunnel.access`). */
|
|
120
|
-
let access;
|
|
121
33
|
/**
|
|
122
34
|
* Whether the Host header names this server. A loopback bind alone does not
|
|
123
35
|
* keep other sites out: a page on evil.example can re-point that name at
|
|
@@ -133,7 +45,7 @@ let access;
|
|
|
133
45
|
* browser only sends it after a CORS preflight, which this server never
|
|
134
46
|
* answers, and a form post cannot set headers at all.
|
|
135
47
|
*/
|
|
136
|
-
function hostAllowed(req) {
|
|
48
|
+
function hostAllowed(req, { publicHost }) {
|
|
137
49
|
if (!LOOPBACK_BIND)
|
|
138
50
|
return true;
|
|
139
51
|
if (forwardedHost(req))
|
|
@@ -145,7 +57,7 @@ function hostAllowed(req) {
|
|
|
145
57
|
return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost") || (!!publicHost && name === publicHost);
|
|
146
58
|
}
|
|
147
59
|
/** Whether the browser addressed the public hostname (directly or, via a proxy, in X-Forwarded-Host). */
|
|
148
|
-
function viaPublicHost(req) {
|
|
60
|
+
function viaPublicHost(req, { publicHost }) {
|
|
149
61
|
if (!publicHost)
|
|
150
62
|
return false;
|
|
151
63
|
const host = forwardedHost(req) || req.headers.host;
|
|
@@ -159,8 +71,9 @@ function viaPublicHost(req) {
|
|
|
159
71
|
* proxy) can deliver the public name in the first place. Returns the reason
|
|
160
72
|
* to refuse, or undefined to proceed.
|
|
161
73
|
*/
|
|
162
|
-
async function accessRefusal(req) {
|
|
163
|
-
|
|
74
|
+
async function accessRefusal(req, gate) {
|
|
75
|
+
const { access } = gate;
|
|
76
|
+
if (!access || !viaPublicHost(req, gate))
|
|
164
77
|
return undefined;
|
|
165
78
|
const raw = req.headers[ACCESS_HEADER];
|
|
166
79
|
const token = Array.isArray(raw) ? raw[0] : raw;
|
|
@@ -183,36 +96,6 @@ function hostnameOf(host) {
|
|
|
183
96
|
return "";
|
|
184
97
|
}
|
|
185
98
|
}
|
|
186
|
-
/** Exit code the `kraftwerk ui` supervisor treats as "relaunch me". */
|
|
187
|
-
export const RESTART_EXIT_CODE = 75;
|
|
188
|
-
/** Whether a supervisor is around to respawn us (set by `kraftwerk ui`). */
|
|
189
|
-
const supervised = () => process.env.KRAFTWERK_UI_SUPERVISED === "1";
|
|
190
|
-
/** Package version as it is on disk right now (re-read per call, detects upgrades). */
|
|
191
|
-
async function getDiskVersion() {
|
|
192
|
-
try {
|
|
193
|
-
const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf8");
|
|
194
|
-
return JSON.parse(raw).version ?? "";
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
return "";
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/** Package version this process loaded with, read once. Works from src/ (tsx) and dist/ alike. */
|
|
201
|
-
let pkgVersion;
|
|
202
|
-
async function getPkgVersion() {
|
|
203
|
-
pkgVersion ??= await getDiskVersion();
|
|
204
|
-
return pkgVersion;
|
|
205
|
-
}
|
|
206
|
-
/** Package name from package.json (registry lookups). */
|
|
207
|
-
async function getPkgName() {
|
|
208
|
-
try {
|
|
209
|
-
const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf8");
|
|
210
|
-
return JSON.parse(raw).name ?? "";
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return "";
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
99
|
/**
|
|
217
100
|
* Whether a state-changing request came from this UI rather than from some
|
|
218
101
|
* other page the user happens to have open. The inspector has no login of
|
|
@@ -249,1241 +132,6 @@ function sameOrigin(req) {
|
|
|
249
132
|
return false;
|
|
250
133
|
}
|
|
251
134
|
}
|
|
252
|
-
async function handleApi(req, res, url) {
|
|
253
|
-
const seg = url.pathname.split("/").filter(Boolean); // ["api", ...]
|
|
254
|
-
const method = req.method ?? "GET";
|
|
255
|
-
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
|
|
256
|
-
return json(res, { error: "cross-origin request refused" }, 403);
|
|
257
|
-
}
|
|
258
|
-
// Channels — shared transcripts with several agents (channels/<slug>/channel.yml + one chat).
|
|
259
|
-
// GET /api/channels | POST /api/channels {name, purpose?, members, responder?}
|
|
260
|
-
// POST /api/channels/from-chat {chatId, name, members, ...} — an agent session becomes a channel
|
|
261
|
-
// GET/PUT/DELETE /api/channels/:slug
|
|
262
|
-
if (seg.length >= 2 && seg[1] === "channels") {
|
|
263
|
-
const view = async (c) => {
|
|
264
|
-
const meta = await ensureChannelChat(c);
|
|
265
|
-
const live = (await listChats()).find((x) => x.id === meta.id);
|
|
266
|
-
return { ...c, chatId: meta.id, busy: live?.busy ?? false, awaitingApproval: live?.awaitingApproval ?? false, updatedAt: meta.updatedAt };
|
|
267
|
-
};
|
|
268
|
-
let body = {};
|
|
269
|
-
if (method === "POST" || method === "PUT") {
|
|
270
|
-
try {
|
|
271
|
-
const raw = await readBody(req);
|
|
272
|
-
if (raw)
|
|
273
|
-
body = JSON.parse(raw);
|
|
274
|
-
}
|
|
275
|
-
catch {
|
|
276
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
try {
|
|
280
|
-
if (seg.length === 2 && method === "GET") {
|
|
281
|
-
const channels = await listChannels();
|
|
282
|
-
return json(res, { root: await channelsRoot(), channels: await Promise.all(channels.map(view)) });
|
|
283
|
-
}
|
|
284
|
-
if (seg.length === 2 && method === "POST") {
|
|
285
|
-
const { chatId: _c, slug: _s, ...input } = body;
|
|
286
|
-
return json(res, await view(await saveChannel(input)));
|
|
287
|
-
}
|
|
288
|
-
if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
|
|
289
|
-
const { chatId, slug: _s, project: _p, ...input } = body;
|
|
290
|
-
if (!chatId)
|
|
291
|
-
return json(res, { error: "chatId is required" }, 400);
|
|
292
|
-
// A project chat's coworkers work in that project: the chat decides, not the body.
|
|
293
|
-
let source = null;
|
|
294
|
-
try {
|
|
295
|
-
source = await readMeta(chatId);
|
|
296
|
-
}
|
|
297
|
-
catch { }
|
|
298
|
-
if (!source)
|
|
299
|
-
return json(res, { error: "chat not found" }, 404);
|
|
300
|
-
const channel = await saveChannel({ ...input, ...(source.scope.kind === "project" ? { project: source.scope.slug } : {}) });
|
|
301
|
-
const converted = await convertChatToChannel(chatId, channel);
|
|
302
|
-
if (converted.error) {
|
|
303
|
-
await deleteChannel(channel.slug).catch(() => { });
|
|
304
|
-
return json(res, { error: converted.error }, 409);
|
|
305
|
-
}
|
|
306
|
-
return json(res, await view(channel));
|
|
307
|
-
}
|
|
308
|
-
if (seg.length === 3) {
|
|
309
|
-
const slug = decodeURIComponent(seg[2]);
|
|
310
|
-
const channel = await getChannel(slug);
|
|
311
|
-
if (!channel)
|
|
312
|
-
return json(res, { error: "channel not found" }, 404);
|
|
313
|
-
if (method === "GET")
|
|
314
|
-
return json(res, await view(channel));
|
|
315
|
-
if (method === "PUT") {
|
|
316
|
-
const { chatId: _c, slug: _s, ...input } = body;
|
|
317
|
-
const saved = await saveChannel({ ...input, slug });
|
|
318
|
-
const meta = await ensureChannelChat(saved);
|
|
319
|
-
await dropChannelSeats(meta.id, saved.members);
|
|
320
|
-
return json(res, await view(saved));
|
|
321
|
-
}
|
|
322
|
-
if (method === "DELETE") {
|
|
323
|
-
const meta = (await listChats()).find((c) => c.scope.kind === "channel" && c.scope.slug === slug);
|
|
324
|
-
if (meta)
|
|
325
|
-
await deleteChat(meta.id);
|
|
326
|
-
await deleteChannel(slug);
|
|
327
|
-
return json(res, { ok: true });
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
catch (err) {
|
|
332
|
-
return json(res, { error: err.message }, 400);
|
|
333
|
-
}
|
|
334
|
-
return json(res, { error: "not found" }, 404);
|
|
335
|
-
}
|
|
336
|
-
// GET /api/notifications — the bell: attention items, newest first, plus unread count.
|
|
337
|
-
// POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
|
|
338
|
-
// DELETE /api/notifications — clear the list.
|
|
339
|
-
if (seg.length >= 2 && seg[1] === "notifications") {
|
|
340
|
-
if (seg.length === 2 && method === "GET")
|
|
341
|
-
return json(res, await listNotifications());
|
|
342
|
-
if (seg.length === 2 && method === "DELETE") {
|
|
343
|
-
await clearNotifications();
|
|
344
|
-
return json(res, { ok: true });
|
|
345
|
-
}
|
|
346
|
-
// POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
|
|
347
|
-
if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
|
|
348
|
-
const result = await startDiagnosis(seg[2]);
|
|
349
|
-
if ("error" in result)
|
|
350
|
-
return json(res, result, result.status);
|
|
351
|
-
return json(res, result);
|
|
352
|
-
}
|
|
353
|
-
if (seg.length === 3 && seg[2] === "read" && method === "POST") {
|
|
354
|
-
let body = {};
|
|
355
|
-
try {
|
|
356
|
-
const raw = await readBody(req);
|
|
357
|
-
if (raw)
|
|
358
|
-
body = JSON.parse(raw);
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
362
|
-
}
|
|
363
|
-
const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
|
|
364
|
-
return json(res, await markNotificationsRead(ids));
|
|
365
|
-
}
|
|
366
|
-
return json(res, { error: "not found" }, 404);
|
|
367
|
-
}
|
|
368
|
-
// GET /api/meta — package version + project name ("environment") for the UI.
|
|
369
|
-
// ?probe=1 marks a discovery probe from another instance: answer identity
|
|
370
|
-
// only, skip our own discovery (two instances must not probe each other
|
|
371
|
-
// recursively).
|
|
372
|
-
if (seg.length === 2 && seg[1] === "meta" && method === "GET") {
|
|
373
|
-
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
374
|
-
const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
|
|
375
|
-
const manual = project?.config.switcher ?? [];
|
|
376
|
-
const discovered = url.searchParams.get("probe") === "1" ? [] : await discoverWorkspaces();
|
|
377
|
-
// Manual switcher entries keep their configured name/icon; discovered
|
|
378
|
-
// workspaces that duplicate one (same url, running) only contribute
|
|
379
|
-
// the live flag. Stopped workspaces never collide — they carry a root,
|
|
380
|
-
// and a manual entry pointing at the same port is just a link.
|
|
381
|
-
const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
|
|
382
|
-
const manualUrls = new Set(manual.map((e) => norm(e.url)));
|
|
383
|
-
const liveUrls = new Set(discovered.filter((d) => d.live).map((d) => norm(d.url)));
|
|
384
|
-
const switcher = [
|
|
385
|
-
...manual.map((e) => (liveUrls.has(norm(e.url)) ? { ...e, live: true } : e)),
|
|
386
|
-
...discovered.filter((d) => !(d.live && manualUrls.has(norm(d.url)))),
|
|
387
|
-
];
|
|
388
|
-
return json(res, {
|
|
389
|
-
version: await getPkgVersion(),
|
|
390
|
-
// A differing disk version means an upgrade landed while this process
|
|
391
|
-
// runs — the UI offers a relaunch when a supervisor can respawn us.
|
|
392
|
-
diskVersion: await getDiskVersion(),
|
|
393
|
-
restartable: supervised(),
|
|
394
|
-
projectName,
|
|
395
|
-
projectIcon: project?.config.icon ?? "",
|
|
396
|
-
projectColor: project?.config.color ?? "",
|
|
397
|
-
projectNamed: !!project?.config.name,
|
|
398
|
-
projectRoot: project?.root ?? getProjectRoot(),
|
|
399
|
-
projectRootLabel: tildify(project?.root ?? getProjectRoot()),
|
|
400
|
-
git: (project?.config.git && project.config.git.enabled !== false) === true,
|
|
401
|
-
repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
|
|
402
|
-
vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
|
|
403
|
-
projects: (project?.config.projects && project.config.projects.enabled !== false) === true,
|
|
404
|
-
publicUrl: (project && publicUrlFor(project)) ?? "",
|
|
405
|
-
cloud: cloudStatus(),
|
|
406
|
-
switcher,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
// GET /api/git — branch, ahead/behind, changed files (the git screen polls
|
|
410
|
-
// this, so it is cached for a beat; ?fresh=1 bypasses the cache).
|
|
411
|
-
if (seg.length === 2 && seg[1] === "git" && method === "GET") {
|
|
412
|
-
return json(res, await gitStatus(url.searchParams.has("fresh")));
|
|
413
|
-
}
|
|
414
|
-
// GET /api/git/diff?path= — unified diff for one file
|
|
415
|
-
if (seg.length === 3 && seg[1] === "git" && seg[2] === "diff" && method === "GET") {
|
|
416
|
-
const file = url.searchParams.get("path");
|
|
417
|
-
if (!file)
|
|
418
|
-
return json(res, { error: "path required" }, 400);
|
|
419
|
-
return json(res, await gitDiff(file));
|
|
420
|
-
}
|
|
421
|
-
// POST /api/git/commit {paths, message} — stage and commit the selection.
|
|
422
|
-
// Commit and push stay manual; the background timer never writes history.
|
|
423
|
-
if (seg.length === 3 && seg[1] === "git" && seg[2] === "commit" && method === "POST") {
|
|
424
|
-
try {
|
|
425
|
-
const body = JSON.parse(await readBody(req));
|
|
426
|
-
const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : [];
|
|
427
|
-
const message = typeof body.message === "string" ? body.message : "";
|
|
428
|
-
const result = await gitCommit(paths, message);
|
|
429
|
-
return json(res, result, result.ok ? 200 : 409);
|
|
430
|
-
}
|
|
431
|
-
catch (err) {
|
|
432
|
-
return json(res, { error: err.message }, 400);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
// POST /api/git/{fetch,pull,push}
|
|
436
|
-
if (seg.length === 3 && seg[1] === "git" && method === "POST" && ["fetch", "pull", "push"].includes(seg[2])) {
|
|
437
|
-
const run = seg[2] === "fetch" ? gitFetch : seg[2] === "pull" ? gitPull : gitPush;
|
|
438
|
-
const result = await run();
|
|
439
|
-
return json(res, result, result.ok ? 200 : 409);
|
|
440
|
-
}
|
|
441
|
-
// GET /api/repos — every clone under the repos root, read live from git
|
|
442
|
-
if (seg.length === 2 && seg[1] === "repos" && method === "GET") {
|
|
443
|
-
return json(res, await listRepos());
|
|
444
|
-
}
|
|
445
|
-
// POST /api/repos {url, name?, branch?, depth?} — clone into the root
|
|
446
|
-
if (seg.length === 2 && seg[1] === "repos" && method === "POST") {
|
|
447
|
-
if ((await openRepos()).off)
|
|
448
|
-
return json(res, { error: "repositories are off" }, 409);
|
|
449
|
-
try {
|
|
450
|
-
const body = JSON.parse(await readBody(req));
|
|
451
|
-
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
452
|
-
const depth = body.depth === undefined ? undefined : typeof body.depth === "number" ? body.depth : Number.NaN;
|
|
453
|
-
const repo = await addRepo({ url: str(body.url) ?? "", name: str(body.name), branch: str(body.branch), depth });
|
|
454
|
-
return json(res, repo, 201);
|
|
455
|
-
}
|
|
456
|
-
catch (err) {
|
|
457
|
-
return json(res, { error: err.message }, 400);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
// GET /api/vibeables — every app folder under the vibeables root | POST {name} — create one with the starter
|
|
461
|
-
if (seg.length === 2 && seg[1] === "vibeables") {
|
|
462
|
-
if (method === "GET")
|
|
463
|
-
return json(res, await listVibeables());
|
|
464
|
-
if (method === "POST") {
|
|
465
|
-
if ((await openVibeables()).off)
|
|
466
|
-
return json(res, { error: "vibeables are off" }, 409);
|
|
467
|
-
try {
|
|
468
|
-
const body = JSON.parse(await readBody(req));
|
|
469
|
-
return json(res, await createVibeable(typeof body.name === "string" ? body.name : ""), 201);
|
|
470
|
-
}
|
|
471
|
-
catch (err) {
|
|
472
|
-
return json(res, { error: err.message }, 400);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
const vibeableError = (err) => {
|
|
477
|
-
const msg = err.message;
|
|
478
|
-
return [/^no vibeable/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
|
|
479
|
-
};
|
|
480
|
-
// GET /api/vibeables/<slug> — how the app previews: mode, static url, dev server state | DELETE — remove the folder
|
|
481
|
-
if (seg.length === 3 && seg[1] === "vibeables") {
|
|
482
|
-
try {
|
|
483
|
-
if (method === "GET")
|
|
484
|
-
return json(res, await vibeableStatus(seg[2]));
|
|
485
|
-
if (method === "DELETE") {
|
|
486
|
-
await deleteVibeable(seg[2]);
|
|
487
|
-
return json(res, { ok: true });
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
const [status, body] = vibeableError(err);
|
|
492
|
-
return json(res, body, status);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
// GET /api/projects — every project folder under the projects root, with the sidebar's layout | POST {title, goal?, slug?} — create one
|
|
496
|
-
// from the starter | PUT {top, sections} — save the sidebar's order and sections
|
|
497
|
-
if (seg.length === 2 && seg[1] === "projects") {
|
|
498
|
-
if (method === "GET")
|
|
499
|
-
return json(res, await listProjects());
|
|
500
|
-
if (method === "PUT") {
|
|
501
|
-
try {
|
|
502
|
-
return json(res, { ok: true, layout: await saveLayout(JSON.parse(await readBody(req))) });
|
|
503
|
-
}
|
|
504
|
-
catch (err) {
|
|
505
|
-
return json(res, { error: err.message }, /are off/.test(err.message) ? 409 : 400);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
if (method === "POST") {
|
|
509
|
-
if ((await openProjects()).off)
|
|
510
|
-
return json(res, { error: "projects are off" }, 409);
|
|
511
|
-
try {
|
|
512
|
-
const body = JSON.parse(await readBody(req));
|
|
513
|
-
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
514
|
-
return json(res, await createProject({ title: str(body.title) ?? "", goal: str(body.goal), slug: str(body.slug) }), 201);
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
return json(res, { error: err.message }, 400);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
const projectError = (err) => {
|
|
522
|
-
const msg = err.message;
|
|
523
|
-
return [/^no project/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
|
|
524
|
-
};
|
|
525
|
-
// GET /api/projects/<slug> — definition, brief, state, log and link states | PUT — save fields | DELETE — remove the folder
|
|
526
|
-
if (seg.length === 3 && seg[1] === "projects") {
|
|
527
|
-
try {
|
|
528
|
-
if (method === "GET") {
|
|
529
|
-
const p = await getProject(seg[2]);
|
|
530
|
-
return p ? json(res, p) : json(res, { error: `no project "${seg[2]}"` }, 404);
|
|
531
|
-
}
|
|
532
|
-
if (method === "PUT") {
|
|
533
|
-
const body = JSON.parse(await readBody(req));
|
|
534
|
-
return json(res, await saveProject(seg[2], body));
|
|
535
|
-
}
|
|
536
|
-
if (method === "DELETE") {
|
|
537
|
-
await deleteProject(seg[2]);
|
|
538
|
-
return json(res, { ok: true });
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
catch (err) {
|
|
542
|
-
const [status, body] = projectError(err);
|
|
543
|
-
return json(res, body, status);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
// POST /api/projects/<slug>/log {entry, actor?} — append a stamped line to log.md
|
|
547
|
-
if (seg.length === 4 && seg[1] === "projects" && seg[3] === "log" && method === "POST") {
|
|
548
|
-
try {
|
|
549
|
-
const body = JSON.parse(await readBody(req));
|
|
550
|
-
const log = await appendProjectLog(seg[2], typeof body.entry === "string" ? body.entry : "", typeof body.actor === "string" ? body.actor : "human:user");
|
|
551
|
-
return json(res, { ok: true, log });
|
|
552
|
-
}
|
|
553
|
-
catch (err) {
|
|
554
|
-
const [status, body] = projectError(err);
|
|
555
|
-
return json(res, body, status);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// POST /api/projects/<slug>/links {kind, target, remove?} — add or drop one linked slug
|
|
559
|
-
if (seg.length === 4 && seg[1] === "projects" && seg[3] === "links" && method === "POST") {
|
|
560
|
-
try {
|
|
561
|
-
const body = JSON.parse(await readBody(req));
|
|
562
|
-
return json(res, await linkProject(seg[2], typeof body.kind === "string" ? body.kind : "", typeof body.target === "string" ? body.target : "", body.remove === true));
|
|
563
|
-
}
|
|
564
|
-
catch (err) {
|
|
565
|
-
const [status, body] = projectError(err);
|
|
566
|
-
return json(res, body, status);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
// GET /api/vibeables/<slug>/events — SSE: file changes (debounced) and dev-server state
|
|
570
|
-
if (seg.length === 4 && seg[1] === "vibeables" && seg[3] === "events" && method === "GET") {
|
|
571
|
-
let dir;
|
|
572
|
-
try {
|
|
573
|
-
dir = (await resolveVibeable(seg[2])).dir;
|
|
574
|
-
}
|
|
575
|
-
catch (err) {
|
|
576
|
-
const [status, body] = vibeableError(err);
|
|
577
|
-
return json(res, body, status);
|
|
578
|
-
}
|
|
579
|
-
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
580
|
-
res.write(":ok\n\n");
|
|
581
|
-
const unsubscribe = subscribeVibeable(seg[2], dir, (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`));
|
|
582
|
-
const heartbeat = setInterval(() => res.write(":hb\n\n"), 25_000);
|
|
583
|
-
req.on("close", () => {
|
|
584
|
-
clearInterval(heartbeat);
|
|
585
|
-
unsubscribe();
|
|
586
|
-
});
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
// POST /api/vibeables/<slug>/dev/{start,stop} — the app's dev command
|
|
590
|
-
if (seg.length === 5 && seg[1] === "vibeables" && seg[3] === "dev" && method === "POST") {
|
|
591
|
-
try {
|
|
592
|
-
if (seg[4] === "start")
|
|
593
|
-
return json(res, await startDev(seg[2]));
|
|
594
|
-
if (seg[4] === "stop")
|
|
595
|
-
return json(res, await stopDev(seg[2]));
|
|
596
|
-
return json(res, { error: "not found" }, 404);
|
|
597
|
-
}
|
|
598
|
-
catch (err) {
|
|
599
|
-
const [status, body] = vibeableError(err);
|
|
600
|
-
return json(res, body, status);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
const repoError = (err) => {
|
|
604
|
-
const msg = err.message;
|
|
605
|
-
return [/^no repository/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
|
|
606
|
-
};
|
|
607
|
-
// GET /api/repos/<slug> — what is happening in the clone: changed files, line counts, recent commits (unpushed marked)
|
|
608
|
-
if (seg.length === 3 && seg[1] === "repos" && method === "GET") {
|
|
609
|
-
try {
|
|
610
|
-
const detail = await repoDetail(seg[2]);
|
|
611
|
-
return detail ? json(res, detail) : json(res, { error: `no repository "${seg[2]}"` }, 404);
|
|
612
|
-
}
|
|
613
|
-
catch (err) {
|
|
614
|
-
const [status, body] = repoError(err);
|
|
615
|
-
return json(res, body, status);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
// GET /api/repos/<slug>/diff?path= — unified diff of one changed file against HEAD
|
|
619
|
-
if (seg.length === 4 && seg[1] === "repos" && seg[3] === "diff" && method === "GET") {
|
|
620
|
-
try {
|
|
621
|
-
const d = await repoDiff(seg[2], url.searchParams.get("path") ?? "");
|
|
622
|
-
return json(res, d, d.error ? (/^not shown/.test(d.error) ? 404 : 400) : 200);
|
|
623
|
-
}
|
|
624
|
-
catch (err) {
|
|
625
|
-
const [status, body] = repoError(err);
|
|
626
|
-
return json(res, body, status);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
// GET /api/repos/<slug>/commits/<hash> — one commit as a patch
|
|
630
|
-
if (seg.length === 5 && seg[1] === "repos" && seg[3] === "commits" && method === "GET") {
|
|
631
|
-
try {
|
|
632
|
-
const d = await repoCommitDiff(seg[2], seg[4]);
|
|
633
|
-
return json(res, d, d.error ? (/^no such commit/.test(d.error) ? 404 : 400) : 200);
|
|
634
|
-
}
|
|
635
|
-
catch (err) {
|
|
636
|
-
const [status, body] = repoError(err);
|
|
637
|
-
return json(res, body, status);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
// POST /api/repos/<slug>/update — fetch, fast-forward when clean
|
|
641
|
-
if (seg.length === 4 && seg[1] === "repos" && seg[3] === "update" && method === "POST") {
|
|
642
|
-
try {
|
|
643
|
-
const result = await updateRepo(seg[2]);
|
|
644
|
-
return json(res, result, result.ok ? 200 : result.repo || result.off ? 409 : 404);
|
|
645
|
-
}
|
|
646
|
-
catch (err) {
|
|
647
|
-
return json(res, { error: err.message }, 400);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
// DELETE /api/repos/<slug>[?force=1] — remove the clone; 409 while it holds unpushed or uncommitted work
|
|
651
|
-
if (seg.length === 3 && seg[1] === "repos" && method === "DELETE") {
|
|
652
|
-
try {
|
|
653
|
-
const force = ["1", "true"].includes(url.searchParams.get("force") ?? "");
|
|
654
|
-
const result = await removeRepo(seg[2], force);
|
|
655
|
-
return json(res, result, result.ok ? 200 : result.conflict || result.off ? 409 : 404);
|
|
656
|
-
}
|
|
657
|
-
catch (err) {
|
|
658
|
-
return json(res, { error: err.message }, 400);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
// GET /api/search/agents — active agents and channels of every reachable workspace (the ⌘K palette)
|
|
662
|
-
if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
|
|
663
|
-
return json(res, await searchAgents());
|
|
664
|
-
}
|
|
665
|
-
// GET /api/workspaces — every known workspace incl. this one, with state + counts (admin screen)
|
|
666
|
-
if (seg.length === 2 && seg[1] === "workspaces" && method === "GET") {
|
|
667
|
-
return json(res, await listWorkspacesDetailed());
|
|
668
|
-
}
|
|
669
|
-
// POST /api/workspaces/start {root} — launch `kraftwerk ui` for a known
|
|
670
|
-
// workspace as a detached process (the switcher's Start button).
|
|
671
|
-
if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "start" && method === "POST") {
|
|
672
|
-
try {
|
|
673
|
-
const { root } = JSON.parse(await readBody(req));
|
|
674
|
-
if (!root)
|
|
675
|
-
return json(res, { error: "root required" }, 400);
|
|
676
|
-
const result = await startWorkspace(root);
|
|
677
|
-
return json(res, result, result.ok ? 200 : 409);
|
|
678
|
-
}
|
|
679
|
-
catch (err) {
|
|
680
|
-
return json(res, { error: err.message }, 400);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
// POST /api/workspaces/stop {root | url} — SIGTERM a running workspace's server
|
|
684
|
-
if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "stop" && method === "POST") {
|
|
685
|
-
try {
|
|
686
|
-
const target = JSON.parse(await readBody(req));
|
|
687
|
-
if (!target.root && !target.url)
|
|
688
|
-
return json(res, { error: "root or url required" }, 400);
|
|
689
|
-
const result = await stopWorkspace(target);
|
|
690
|
-
return json(res, result, result.ok ? 200 : 409);
|
|
691
|
-
}
|
|
692
|
-
catch (err) {
|
|
693
|
-
return json(res, { error: err.message }, 400);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
// POST /api/workspaces/forget {root} — drop a workspace from the registry
|
|
697
|
-
if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "forget" && method === "POST") {
|
|
698
|
-
try {
|
|
699
|
-
const { root } = JSON.parse(await readBody(req));
|
|
700
|
-
if (!root)
|
|
701
|
-
return json(res, { error: "root required" }, 400);
|
|
702
|
-
return json(res, { ok: await forgetWorkspace(root) });
|
|
703
|
-
}
|
|
704
|
-
catch (err) {
|
|
705
|
-
return json(res, { error: err.message }, 400);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
// GET /api/settings — kraftwerk.yml (parsed + resolved paths) for the settings page
|
|
709
|
-
if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
|
|
710
|
-
return json(res, await getSettings());
|
|
711
|
-
}
|
|
712
|
-
// PUT /api/settings — write the UI-editable subset (name, icon, switcher, git, repos) back
|
|
713
|
-
if (seg.length === 2 && seg[1] === "settings" && method === "PUT") {
|
|
714
|
-
try {
|
|
715
|
-
const input = JSON.parse(await readBody(req));
|
|
716
|
-
return json(res, await saveSettings(input));
|
|
717
|
-
}
|
|
718
|
-
catch (err) {
|
|
719
|
-
return json(res, { error: err.message }, 400);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
// GET /api/update-check — ask the npm registry for the latest published version
|
|
723
|
-
if (seg.length === 2 && seg[1] === "update-check" && method === "GET") {
|
|
724
|
-
const name = await getPkgName();
|
|
725
|
-
const current = await getDiskVersion();
|
|
726
|
-
try {
|
|
727
|
-
const r = await fetch(`https://registry.npmjs.org/${name}/latest`, {
|
|
728
|
-
signal: AbortSignal.timeout(5_000),
|
|
729
|
-
});
|
|
730
|
-
if (!r.ok)
|
|
731
|
-
throw new Error(String(r.status));
|
|
732
|
-
const latest = (await r.json()).version ?? "";
|
|
733
|
-
return json(res, { name, current, latest });
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
return json(res, { name, current, latest: "", error: "npm registry unreachable" }, 502);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
// GET /api/update — state and log of the self-update, plus whether one can run here.
|
|
740
|
-
// POST /api/update {version?} — run `npm i -g <name>@<version>`; 409 when refused or already running.
|
|
741
|
-
if (seg.length === 2 && seg[1] === "update") {
|
|
742
|
-
const name = await getPkgName();
|
|
743
|
-
if (method === "GET") {
|
|
744
|
-
const can = canSelfUpdate(name);
|
|
745
|
-
return json(res, { ...updateStatus(), name, available: can.ok, reason: can.ok ? undefined : can.reason, restartable: supervised() });
|
|
746
|
-
}
|
|
747
|
-
if (method === "POST") {
|
|
748
|
-
let version = "latest";
|
|
749
|
-
try {
|
|
750
|
-
const body = JSON.parse((await readBody(req)) || "{}");
|
|
751
|
-
if (typeof body.version === "string" && body.version)
|
|
752
|
-
version = body.version;
|
|
753
|
-
}
|
|
754
|
-
catch {
|
|
755
|
-
return json(res, { error: "invalid body" }, 400);
|
|
756
|
-
}
|
|
757
|
-
try {
|
|
758
|
-
return json(res, { ...startUpdate(name, version), name }, 202);
|
|
759
|
-
}
|
|
760
|
-
catch (err) {
|
|
761
|
-
return json(res, { error: err.message }, 409);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
// POST /api/restart — exit with the respawn code; the `kraftwerk ui` supervisor relaunches
|
|
766
|
-
if (seg.length === 2 && seg[1] === "restart" && method === "POST") {
|
|
767
|
-
if (!supervised()) {
|
|
768
|
-
return json(res, { error: "not supervised — restart `kraftwerk ui` manually" }, 400);
|
|
769
|
-
}
|
|
770
|
-
json(res, { ok: true });
|
|
771
|
-
setTimeout(() => {
|
|
772
|
-
disposeAllBackends();
|
|
773
|
-
disposeAllDevs();
|
|
774
|
-
process.exit(RESTART_EXIT_CODE);
|
|
775
|
-
}, 150);
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
// GET /api/runs
|
|
779
|
-
if (seg.length === 2 && seg[1] === "runs" && method === "GET") {
|
|
780
|
-
return json(res, { outputDir: getOutputDir(), runs: await listRuns() });
|
|
781
|
-
}
|
|
782
|
-
// GET /api/runs/:id
|
|
783
|
-
if (seg.length === 3 && seg[1] === "runs" && method === "GET") {
|
|
784
|
-
try {
|
|
785
|
-
const run = await getRun(seg[2]);
|
|
786
|
-
return run ? json(res, run) : json(res, { error: "not found" }, 404);
|
|
787
|
-
}
|
|
788
|
-
catch {
|
|
789
|
-
return json(res, { error: "invalid run id" }, 400);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
// GET /api/runs/:id/file?name=...&raw=1
|
|
793
|
-
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "file" && method === "GET") {
|
|
794
|
-
const name = url.searchParams.get("name") ?? "";
|
|
795
|
-
const raw = url.searchParams.get("raw") === "1";
|
|
796
|
-
let file;
|
|
797
|
-
try {
|
|
798
|
-
file = await readRunFile(seg[2], name);
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
return json(res, { error: "invalid request" }, 400);
|
|
802
|
-
}
|
|
803
|
-
if (!file)
|
|
804
|
-
return json(res, { error: "not found" }, 404);
|
|
805
|
-
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
806
|
-
const buf = await fs.readFile(file.absPath);
|
|
807
|
-
if (raw) {
|
|
808
|
-
// Agent-written files must not run as the inspector's origin: the
|
|
809
|
-
// sandbox keeps scripts working (interactive reports) but makes the
|
|
810
|
-
// origin opaque, so no fetch() into /api and no cookies. PDFs are
|
|
811
|
-
// exempt — Chrome's viewer refuses sandboxed documents, and PDF
|
|
812
|
-
// scripting has no DOM or origin access.
|
|
813
|
-
res.writeHead(200, {
|
|
814
|
-
"content-type": MIME[ext] ?? "text/plain; charset=utf-8",
|
|
815
|
-
"cache-control": "no-store",
|
|
816
|
-
"x-content-type-options": "nosniff",
|
|
817
|
-
...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
|
|
818
|
-
});
|
|
819
|
-
return void res.end(buf);
|
|
820
|
-
}
|
|
821
|
-
let text = buf.toString("utf8");
|
|
822
|
-
let truncated = false;
|
|
823
|
-
if (text.length > MAX_TEXT) {
|
|
824
|
-
text = text.slice(-MAX_TEXT);
|
|
825
|
-
truncated = true;
|
|
826
|
-
}
|
|
827
|
-
return json(res, { name, size: file.size, truncated, content: text });
|
|
828
|
-
}
|
|
829
|
-
// POST /api/runs/:id/decision {decision, note?} — a person answers the
|
|
830
|
-
// step that wrote decision-request.json; the answer is written once.
|
|
831
|
-
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "decision" && method === "POST") {
|
|
832
|
-
let runDir;
|
|
833
|
-
let body;
|
|
834
|
-
try {
|
|
835
|
-
runDir = safeRunDir(seg[2]);
|
|
836
|
-
body = JSON.parse((await readBody(req)) || "{}");
|
|
837
|
-
}
|
|
838
|
-
catch {
|
|
839
|
-
return json(res, { error: "invalid request" }, 400);
|
|
840
|
-
}
|
|
841
|
-
const r = await decide(runDir, body ?? {});
|
|
842
|
-
return r.ok ? json(res, r.answer) : json(res, { error: r.error }, r.status);
|
|
843
|
-
}
|
|
844
|
-
// POST /api/runs/:id/stop
|
|
845
|
-
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "stop" && method === "POST") {
|
|
846
|
-
return stopRun(seg[2])
|
|
847
|
-
? json(res, { stopped: true })
|
|
848
|
-
: json(res, { error: "nothing to stop: no launcher of this inspector and no sandbox container for this run" }, 404);
|
|
849
|
-
}
|
|
850
|
-
// DELETE /api/runs/:id — refused while the run is live (stop it first)
|
|
851
|
-
if (seg.length === 3 && seg[1] === "runs" && method === "DELETE") {
|
|
852
|
-
let outcome;
|
|
853
|
-
try {
|
|
854
|
-
outcome = await deleteRun(seg[2]);
|
|
855
|
-
}
|
|
856
|
-
catch {
|
|
857
|
-
return json(res, { error: "invalid run id" }, 400);
|
|
858
|
-
}
|
|
859
|
-
if (outcome === "missing")
|
|
860
|
-
return json(res, { error: "not found" }, 404);
|
|
861
|
-
if (outcome === "running")
|
|
862
|
-
return json(res, { error: "run is still running — stop it first" }, 409);
|
|
863
|
-
return json(res, { deleted: true });
|
|
864
|
-
}
|
|
865
|
-
// GET /api/workflows
|
|
866
|
-
if (seg.length === 2 && seg[1] === "workflows" && method === "GET") {
|
|
867
|
-
return json(res, await listWorkflows());
|
|
868
|
-
}
|
|
869
|
-
// GET /api/workflows/:slug
|
|
870
|
-
if (seg.length === 3 && seg[1] === "workflows" && method === "GET") {
|
|
871
|
-
const wf = await getWorkflow(decodeURIComponent(seg[2]));
|
|
872
|
-
return wf ? json(res, wf) : json(res, { error: "not found" }, 404);
|
|
873
|
-
}
|
|
874
|
-
// DELETE /api/workflows/:slug — move it to the trash
|
|
875
|
-
if (seg.length === 3 && seg[1] === "workflows" && method === "DELETE") {
|
|
876
|
-
const outcome = await deleteWorkflow(decodeURIComponent(seg[2]));
|
|
877
|
-
if (outcome === "missing")
|
|
878
|
-
return json(res, { error: "not found" }, 404);
|
|
879
|
-
if (outcome === "running")
|
|
880
|
-
return json(res, { error: "a run of this workflow is still running — stop it first" }, 409);
|
|
881
|
-
return json(res, { ok: true });
|
|
882
|
-
}
|
|
883
|
-
// GET/POST /api/workflows/:slug/run
|
|
884
|
-
if (seg.length === 4 && seg[1] === "workflows" && seg[3] === "run") {
|
|
885
|
-
if (method === "GET")
|
|
886
|
-
return json(res, dockerStatus());
|
|
887
|
-
if (method === "POST") {
|
|
888
|
-
const wf = await getWorkflow(decodeURIComponent(seg[2]));
|
|
889
|
-
if (!wf || wf.error || !wf.name)
|
|
890
|
-
return json(res, { error: "workflow not found or broken" }, 404);
|
|
891
|
-
let body;
|
|
892
|
-
try {
|
|
893
|
-
body = JSON.parse(await readBody(req));
|
|
894
|
-
}
|
|
895
|
-
catch {
|
|
896
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
897
|
-
}
|
|
898
|
-
const request = (body.request ?? "").trim();
|
|
899
|
-
if (!request && wf.usesRequest)
|
|
900
|
-
return json(res, { error: "this workflow needs a request" }, 400);
|
|
901
|
-
try {
|
|
902
|
-
const { runId } = triggerRun({
|
|
903
|
-
workflowName: wf.name,
|
|
904
|
-
request,
|
|
905
|
-
sandbox: body.sandbox ?? true,
|
|
906
|
-
ssh: !!body.ssh,
|
|
907
|
-
});
|
|
908
|
-
return json(res, { runId });
|
|
909
|
-
}
|
|
910
|
-
catch (err) {
|
|
911
|
-
return json(res, { error: err.message }, 503);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
// GET/POST /api/knowledge — bundle index / create a bundle
|
|
916
|
-
if (seg.length === 2 && seg[1] === "knowledge") {
|
|
917
|
-
if (method === "GET")
|
|
918
|
-
return json(res, await knowledgeIndex());
|
|
919
|
-
if (method === "POST") {
|
|
920
|
-
let body;
|
|
921
|
-
try {
|
|
922
|
-
body = JSON.parse(await readBody(req));
|
|
923
|
-
}
|
|
924
|
-
catch {
|
|
925
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
926
|
-
}
|
|
927
|
-
try {
|
|
928
|
-
return json(res, await createBundle(String(body.name ?? "").trim()));
|
|
929
|
-
}
|
|
930
|
-
catch (err) {
|
|
931
|
-
return json(res, { error: err.message }, 400);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
// GET /api/knowledge/:bundle — concepts + log
|
|
936
|
-
if (seg.length === 3 && seg[1] === "knowledge" && method === "GET") {
|
|
937
|
-
try {
|
|
938
|
-
const detail = await bundleDetail(decodeURIComponent(seg[2]));
|
|
939
|
-
return detail ? json(res, detail) : json(res, { error: "not found" }, 404);
|
|
940
|
-
}
|
|
941
|
-
catch {
|
|
942
|
-
return json(res, { error: "invalid bundle name" }, 400);
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
// DELETE /api/knowledge/:bundle — move the bundle to the trash
|
|
946
|
-
if (seg.length === 3 && seg[1] === "knowledge" && method === "DELETE") {
|
|
947
|
-
try {
|
|
948
|
-
await deleteBundle(decodeURIComponent(seg[2]));
|
|
949
|
-
return json(res, { ok: true });
|
|
950
|
-
}
|
|
951
|
-
catch (err) {
|
|
952
|
-
return json(res, { error: err.message }, 400);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
// GET /api/trash — what was deleted, newest first; DELETE /api/trash — empty it for good.
|
|
956
|
-
// POST /api/trash/restore {id} — put one back; POST /api/trash/purge {id} — delete one for good.
|
|
957
|
-
if (seg.length >= 2 && seg[1] === "trash") {
|
|
958
|
-
try {
|
|
959
|
-
if (seg.length === 2 && method === "GET")
|
|
960
|
-
return json(res, { root: tildify(await trashRoot()), entries: await listTrash() });
|
|
961
|
-
if (seg.length === 2 && method === "DELETE")
|
|
962
|
-
return json(res, { ok: true, purged: await emptyTrash() });
|
|
963
|
-
if (seg.length === 3 && method === "POST" && (seg[2] === "restore" || seg[2] === "purge")) {
|
|
964
|
-
const { id } = JSON.parse(await readBody(req));
|
|
965
|
-
if (!id)
|
|
966
|
-
return json(res, { error: "id is required" }, 400);
|
|
967
|
-
return json(res, seg[2] === "restore" ? await restoreFromTrash(id) : await purgeFromTrash(id));
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
catch (err) {
|
|
971
|
-
return json(res, { error: err.message }, 400);
|
|
972
|
-
}
|
|
973
|
-
return json(res, { error: "not found" }, 404);
|
|
974
|
-
}
|
|
975
|
-
// GET/POST /api/knowledge/:bundle/concept?id=... — read / write one concept
|
|
976
|
-
if (seg.length === 4 && seg[1] === "knowledge" && seg[3] === "concept") {
|
|
977
|
-
const bundle = decodeURIComponent(seg[2]);
|
|
978
|
-
if (method === "GET") {
|
|
979
|
-
const id = url.searchParams.get("id") ?? "";
|
|
980
|
-
try {
|
|
981
|
-
const concept = await conceptDetail(bundle, id);
|
|
982
|
-
return concept ? json(res, concept) : json(res, { error: "not found" }, 404);
|
|
983
|
-
}
|
|
984
|
-
catch (err) {
|
|
985
|
-
return json(res, { error: err.message }, 400);
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
if (method === "POST") {
|
|
989
|
-
let body;
|
|
990
|
-
try {
|
|
991
|
-
body = JSON.parse(await readBody(req));
|
|
992
|
-
}
|
|
993
|
-
catch {
|
|
994
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
995
|
-
}
|
|
996
|
-
if (!body.id || !body.content?.trim()) {
|
|
997
|
-
return json(res, { error: "id and content are required" }, 400);
|
|
998
|
-
}
|
|
999
|
-
try {
|
|
1000
|
-
return json(res, await putConcept(bundle, body.id, body.content));
|
|
1001
|
-
}
|
|
1002
|
-
catch (err) {
|
|
1003
|
-
return json(res, { error: err.message }, 400);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
// POST /api/knowledge/:bundle/verify — human verification from the UI
|
|
1008
|
-
if (seg.length === 4 && seg[1] === "knowledge" && seg[3] === "verify" && method === "POST") {
|
|
1009
|
-
let body;
|
|
1010
|
-
try {
|
|
1011
|
-
body = JSON.parse(await readBody(req));
|
|
1012
|
-
}
|
|
1013
|
-
catch {
|
|
1014
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
1015
|
-
}
|
|
1016
|
-
if (!body.id)
|
|
1017
|
-
return json(res, { error: "id is required" }, 400);
|
|
1018
|
-
try {
|
|
1019
|
-
return json(res, await verifyFromUi(decodeURIComponent(seg[2]), body.id));
|
|
1020
|
-
}
|
|
1021
|
-
catch (err) {
|
|
1022
|
-
return json(res, { error: err.message }, 400);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
// GET /api/skills — discovered skills (workspace root + .claude/skills + ~/.claude/skills)
|
|
1026
|
-
if (seg.length === 2 && seg[1] === "skills" && method === "GET") {
|
|
1027
|
-
const [root, skills] = await Promise.all([skillsRoot(), listSkills()]);
|
|
1028
|
-
return json(res, { root, skills });
|
|
1029
|
-
}
|
|
1030
|
-
// GET /api/skills/:name — one skill with SKILL.md content + bundled files
|
|
1031
|
-
if (seg.length === 3 && seg[1] === "skills" && method === "GET") {
|
|
1032
|
-
const skill = await getSkill(decodeURIComponent(seg[2]));
|
|
1033
|
-
return skill ? json(res, skill) : json(res, { error: "not found" }, 404);
|
|
1034
|
-
}
|
|
1035
|
-
// GET/POST /api/agents — member list / create a member
|
|
1036
|
-
if (seg.length === 2 && seg[1] === "agents") {
|
|
1037
|
-
if (method === "GET") {
|
|
1038
|
-
return json(res, { root: await agentsRoot(), agents: await listAgents() });
|
|
1039
|
-
}
|
|
1040
|
-
if (method === "POST") {
|
|
1041
|
-
try {
|
|
1042
|
-
const body = JSON.parse(await readBody(req));
|
|
1043
|
-
return json(res, await saveAgent({ ...body, slug: undefined }));
|
|
1044
|
-
}
|
|
1045
|
-
catch (err) {
|
|
1046
|
-
return json(res, { error: err.message }, 400);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
// POST /api/agents/:slug/archive — archive/unarchive a member ({ archived: boolean })
|
|
1051
|
-
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "archive" && method === "POST") {
|
|
1052
|
-
try {
|
|
1053
|
-
const body = JSON.parse(await readBody(req));
|
|
1054
|
-
return json(res, await setAgentArchived(decodeURIComponent(seg[2]), body.archived === true));
|
|
1055
|
-
}
|
|
1056
|
-
catch (err) {
|
|
1057
|
-
return json(res, { error: err.message }, 400);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
// GET/POST /api/agents/:slug/skills — the agent's own skills / create or update one
|
|
1061
|
-
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "skills") {
|
|
1062
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1063
|
-
try {
|
|
1064
|
-
if (method === "GET")
|
|
1065
|
-
return json(res, { skills: await listAgentSkills(slug) });
|
|
1066
|
-
if (method === "POST") {
|
|
1067
|
-
const body = JSON.parse(await readBody(req));
|
|
1068
|
-
return json(res, await saveAgentSkill(slug, String(body.name ?? ""), String(body.content ?? "")));
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
catch (err) {
|
|
1072
|
-
return json(res, { error: err.message }, 400);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
// GET/DELETE /api/agents/:slug/skills/:name — one agent skill with content / delete
|
|
1076
|
-
if (seg.length === 5 && seg[1] === "agents" && seg[3] === "skills") {
|
|
1077
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1078
|
-
const name = decodeURIComponent(seg[4]);
|
|
1079
|
-
try {
|
|
1080
|
-
if (method === "GET") {
|
|
1081
|
-
const skill = (await listAgentSkills(slug)).find((s) => s.name.toLowerCase() === name.toLowerCase());
|
|
1082
|
-
if (!skill)
|
|
1083
|
-
return json(res, { error: "not found" }, 404);
|
|
1084
|
-
return json(res, { ...skill, content: await readSkill(skill) });
|
|
1085
|
-
}
|
|
1086
|
-
if (method === "DELETE") {
|
|
1087
|
-
await deleteAgentSkill(slug, name);
|
|
1088
|
-
return json(res, { ok: true });
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
catch (err) {
|
|
1092
|
-
return json(res, { error: err.message }, 400);
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
// GET /api/routines — every agent's routines with run state and the workflows each one names
|
|
1096
|
-
if (seg.length === 2 && seg[1] === "routines" && method === "GET") {
|
|
1097
|
-
return json(res, { routines: await allRoutines((await listWorkflows()).workflows) });
|
|
1098
|
-
}
|
|
1099
|
-
// GET/POST /api/agents/:slug/routines — list (with run state) / upsert
|
|
1100
|
-
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "routines") {
|
|
1101
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1102
|
-
try {
|
|
1103
|
-
if (method === "GET")
|
|
1104
|
-
return json(res, { routines: await routineStatuses(slug) });
|
|
1105
|
-
if (method === "POST") {
|
|
1106
|
-
const body = JSON.parse(await readBody(req));
|
|
1107
|
-
return json(res, await saveRoutine(slug, body));
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
catch (err) {
|
|
1111
|
-
return json(res, { error: err.message }, 400);
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
// DELETE /api/agents/:slug/routines/:id | POST /api/agents/:slug/routines/:id/run
|
|
1115
|
-
if (seg.length >= 5 && seg[1] === "agents" && seg[3] === "routines") {
|
|
1116
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1117
|
-
const id = decodeURIComponent(seg[4]);
|
|
1118
|
-
try {
|
|
1119
|
-
if (seg.length === 5 && method === "DELETE") {
|
|
1120
|
-
await deleteRoutine(slug, id);
|
|
1121
|
-
return json(res, { ok: true });
|
|
1122
|
-
}
|
|
1123
|
-
if (seg.length === 6 && seg[5] === "run" && method === "POST") {
|
|
1124
|
-
return json(res, await runRoutineNow(slug, id));
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
catch (err) {
|
|
1128
|
-
return json(res, { error: err.message }, 400);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
// Files: GET /api/files (the roots: workspace + one per project), GET /api/files/list?scope&path,
|
|
1132
|
-
// GET /api/files/raw?scope&path[&download=1], POST /api/files/upload?scope&path (raw body, x-file-name),
|
|
1133
|
-
// POST /api/files/folder {scope, path}, POST /api/files/move {scope, from, to}, DELETE /api/files?scope&path (to the trash).
|
|
1134
|
-
if (seg[1] === "files" && seg.length <= 3) {
|
|
1135
|
-
const scope = url.searchParams.get("scope") ?? "workspace";
|
|
1136
|
-
const rel = url.searchParams.get("path") ?? "";
|
|
1137
|
-
try {
|
|
1138
|
-
if (seg.length === 2 && method === "GET")
|
|
1139
|
-
return json(res, { scopes: await listScopes() });
|
|
1140
|
-
if (seg.length === 2 && method === "DELETE") {
|
|
1141
|
-
await deleteFile(scope, rel);
|
|
1142
|
-
return json(res, { ok: true });
|
|
1143
|
-
}
|
|
1144
|
-
if (seg[2] === "list" && method === "GET")
|
|
1145
|
-
return json(res, await listFiles(scope, rel));
|
|
1146
|
-
if (seg[2] === "recent" && method === "GET")
|
|
1147
|
-
return json(res, (await filesSummary(scope, 5)) ?? { root: "", count: 0, recent: [] });
|
|
1148
|
-
if (seg[2] === "raw" && method === "GET") {
|
|
1149
|
-
const f = await openFile(scope, rel);
|
|
1150
|
-
res.writeHead(200, {
|
|
1151
|
-
"content-type": f.mime,
|
|
1152
|
-
"content-length": f.size,
|
|
1153
|
-
"cache-control": "no-store",
|
|
1154
|
-
"x-content-type-options": "nosniff",
|
|
1155
|
-
// Anything in here may come from outside: a page, an SVG or XML renders without scripts, in an origin of its own.
|
|
1156
|
-
// (Not on PDFs and media: a sandboxed document cannot show the browser's PDF viewer.)
|
|
1157
|
-
...(/^(text\/html|image\/svg|text\/xml|application\/xml)/.test(f.mime) ? { "content-security-policy": "sandbox" } : {}),
|
|
1158
|
-
"content-disposition": `${url.searchParams.get("download") ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(f.name)}`,
|
|
1159
|
-
});
|
|
1160
|
-
f.stream.pipe(res);
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
if (seg[2] === "upload" && method === "POST") {
|
|
1164
|
-
const name = decodeURIComponent(String(req.headers["x-file-name"] ?? "file"));
|
|
1165
|
-
return json(res, await saveUpload(scope, rel, name, req), 201);
|
|
1166
|
-
}
|
|
1167
|
-
if (seg[2] === "folder" && method === "POST") {
|
|
1168
|
-
const body = JSON.parse(await readBody(req));
|
|
1169
|
-
return json(res, await makeFolder(body.scope ?? scope, String(body.path ?? "")), 201);
|
|
1170
|
-
}
|
|
1171
|
-
if (seg[2] === "move" && method === "POST") {
|
|
1172
|
-
const body = JSON.parse(await readBody(req));
|
|
1173
|
-
await moveFile(body.scope ?? scope, String(body.from ?? ""), String(body.to ?? ""));
|
|
1174
|
-
return json(res, { ok: true });
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
catch (err) {
|
|
1178
|
-
const msg = err.message;
|
|
1179
|
-
return json(res, { error: msg }, /^no (file|folder|project)/.test(msg) ? 404 : 400);
|
|
1180
|
-
}
|
|
1181
|
-
return json(res, { error: "not found" }, 404);
|
|
1182
|
-
}
|
|
1183
|
-
// GET /api/attention — what needs you: waiting approvals and questions, unread failures, with their owner
|
|
1184
|
-
if (seg.length === 2 && seg[1] === "attention" && method === "GET") {
|
|
1185
|
-
return json(res, await listAttention());
|
|
1186
|
-
}
|
|
1187
|
-
// GET /api/agent-status — per agent: waiting, working, next routine, last active (the rail's status line)
|
|
1188
|
-
if (seg.length === 2 && seg[1] === "agent-status" && method === "GET") {
|
|
1189
|
-
return json(res, await agentStatuses());
|
|
1190
|
-
}
|
|
1191
|
-
// GET /api/agents/:slug/journal — the journal; POST {entry, kind?} — a human adds a line
|
|
1192
|
-
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "journal") {
|
|
1193
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1194
|
-
try {
|
|
1195
|
-
if (method === "GET")
|
|
1196
|
-
return json(res, { journal: await readJournal(slug) });
|
|
1197
|
-
if (method === "POST") {
|
|
1198
|
-
const body = JSON.parse(await readBody(req));
|
|
1199
|
-
return json(res, { journal: await appendJournal(slug, String(body.entry ?? ""), { kind: body.kind, actor: UI_ACTOR }) });
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
catch (err) {
|
|
1203
|
-
return json(res, { error: err.message }, 400);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
// GET/PUT/DELETE /api/agents/:slug
|
|
1207
|
-
if (seg.length === 3 && seg[1] === "agents") {
|
|
1208
|
-
const slug = decodeURIComponent(seg[2]);
|
|
1209
|
-
try {
|
|
1210
|
-
if (method === "GET") {
|
|
1211
|
-
const agent = await getAgent(slug);
|
|
1212
|
-
return agent ? json(res, agent) : json(res, { error: "not found" }, 404);
|
|
1213
|
-
}
|
|
1214
|
-
if (method === "PUT") {
|
|
1215
|
-
const body = JSON.parse(await readBody(req));
|
|
1216
|
-
return json(res, await saveAgent({ ...body, slug }));
|
|
1217
|
-
}
|
|
1218
|
-
if (method === "DELETE") {
|
|
1219
|
-
await deleteAgent(slug);
|
|
1220
|
-
return json(res, { ok: true });
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
catch (err) {
|
|
1224
|
-
return json(res, { error: err.message }, 400);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
// GET/POST /api/chats
|
|
1228
|
-
if (seg.length === 2 && seg[1] === "chats") {
|
|
1229
|
-
if (method === "GET")
|
|
1230
|
-
return json(res, { chats: await listChats() });
|
|
1231
|
-
if (method === "POST") {
|
|
1232
|
-
let body;
|
|
1233
|
-
try {
|
|
1234
|
-
body = JSON.parse(await readBody(req));
|
|
1235
|
-
}
|
|
1236
|
-
catch {
|
|
1237
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
1238
|
-
}
|
|
1239
|
-
let agent = body.agent;
|
|
1240
|
-
let scope;
|
|
1241
|
-
if (body.scope?.kind === "agent" && body.scope.slug) {
|
|
1242
|
-
// Agent sessions run on the agent's configured harness.
|
|
1243
|
-
const def = await getAgent(body.scope.slug).catch(() => null);
|
|
1244
|
-
if (!def)
|
|
1245
|
-
return json(res, { error: "agent not found" }, 404);
|
|
1246
|
-
scope = { kind: "agent", slug: def.slug };
|
|
1247
|
-
agent = def.harness;
|
|
1248
|
-
}
|
|
1249
|
-
else if (body.scope?.kind === "run" && body.scope.runId) {
|
|
1250
|
-
scope = { kind: "run", runId: body.scope.runId };
|
|
1251
|
-
}
|
|
1252
|
-
else if (body.scope?.kind === "kraftwerk") {
|
|
1253
|
-
scope = { kind: "kraftwerk" };
|
|
1254
|
-
}
|
|
1255
|
-
else if (body.scope?.kind === "knowledge") {
|
|
1256
|
-
scope = { kind: "knowledge", ...(body.scope.bundle ? { bundle: body.scope.bundle } : {}) };
|
|
1257
|
-
}
|
|
1258
|
-
else if (body.scope?.kind === "project" && body.scope.slug) {
|
|
1259
|
-
// A project chat needs its folder; a missing one is a 404 here, not a chat that apologizes.
|
|
1260
|
-
let found;
|
|
1261
|
-
try {
|
|
1262
|
-
found = await getProject(body.scope.slug);
|
|
1263
|
-
}
|
|
1264
|
-
catch (err) {
|
|
1265
|
-
const [status, e] = projectError(err);
|
|
1266
|
-
return json(res, e, status);
|
|
1267
|
-
}
|
|
1268
|
-
if (!found)
|
|
1269
|
-
return json(res, { error: "project not found" }, 404);
|
|
1270
|
-
scope = { kind: "project", slug: found.slug };
|
|
1271
|
-
// Like agent sessions: the project decides the harness, not the caller.
|
|
1272
|
-
agent = found.harness;
|
|
1273
|
-
}
|
|
1274
|
-
else {
|
|
1275
|
-
scope = { kind: "general" };
|
|
1276
|
-
}
|
|
1277
|
-
if (!["claude", "codex", "pi"].includes(agent)) {
|
|
1278
|
-
return json(res, { error: "agent must be claude, codex, or pi" }, 400);
|
|
1279
|
-
}
|
|
1280
|
-
try {
|
|
1281
|
-
return json(res, await createChat({ agent, scope, ...(typeof body.resume === "string" && body.resume ? { resume: body.resume } : {}) }));
|
|
1282
|
-
}
|
|
1283
|
-
catch (err) {
|
|
1284
|
-
return json(res, { error: err.message }, 400);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
// GET /api/agent-sessions?agent=claude|codex — the agent's own sessions in the project root
|
|
1289
|
-
if (seg.length === 2 && seg[1] === "agent-sessions" && method === "GET") {
|
|
1290
|
-
const agent = url.searchParams.get("agent") ?? "claude";
|
|
1291
|
-
if (!["claude", "codex", "pi"].includes(agent))
|
|
1292
|
-
return json(res, { error: "agent must be claude, codex, or pi" }, 400);
|
|
1293
|
-
try {
|
|
1294
|
-
return json(res, { sessions: await listAgentSessionsFor(agent) });
|
|
1295
|
-
}
|
|
1296
|
-
catch (err) {
|
|
1297
|
-
return json(res, { error: err.message }, 502);
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
// DELETE /api/chats/:id
|
|
1301
|
-
if (seg.length === 3 && seg[1] === "chats" && method === "DELETE") {
|
|
1302
|
-
try {
|
|
1303
|
-
const result = await deleteChat(seg[2]);
|
|
1304
|
-
return json(res, result, result.error ? 404 : 200);
|
|
1305
|
-
}
|
|
1306
|
-
catch {
|
|
1307
|
-
return json(res, { error: "invalid chat id" }, 400);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
// PATCH /api/chats/:id {title} — rename the chat
|
|
1311
|
-
if (seg.length === 3 && seg[1] === "chats" && method === "PATCH") {
|
|
1312
|
-
try {
|
|
1313
|
-
const body = JSON.parse(await readBody(req));
|
|
1314
|
-
const result = await renameChat(seg[2], body.title);
|
|
1315
|
-
return json(res, result, result.error ? (result.error === "not found" ? 404 : 400) : 200);
|
|
1316
|
-
}
|
|
1317
|
-
catch {
|
|
1318
|
-
return json(res, { error: "invalid chat id or body" }, 400);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
// GET /api/chats/:id
|
|
1322
|
-
if (seg.length === 3 && seg[1] === "chats" && method === "GET") {
|
|
1323
|
-
try {
|
|
1324
|
-
const chat = await getChat(seg[2]);
|
|
1325
|
-
return chat ? json(res, chat) : json(res, { error: "not found" }, 404);
|
|
1326
|
-
}
|
|
1327
|
-
catch {
|
|
1328
|
-
return json(res, { error: "invalid chat id" }, 400);
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
// GET /api/chats/:id/events?after=N — SSE stream of thread events.
|
|
1332
|
-
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "events" && method === "GET") {
|
|
1333
|
-
const afterSeq = Number(url.searchParams.get("after") ?? "0") || 0;
|
|
1334
|
-
const send = (ev) => {
|
|
1335
|
-
res.write(`id: ${ev.seq}\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
1336
|
-
};
|
|
1337
|
-
let unsubscribe = null;
|
|
1338
|
-
try {
|
|
1339
|
-
res.writeHead(200, {
|
|
1340
|
-
"content-type": "text/event-stream",
|
|
1341
|
-
"cache-control": "no-store",
|
|
1342
|
-
connection: "keep-alive",
|
|
1343
|
-
});
|
|
1344
|
-
unsubscribe = await subscribeChat(seg[2], afterSeq, send);
|
|
1345
|
-
}
|
|
1346
|
-
catch {
|
|
1347
|
-
return void res.end();
|
|
1348
|
-
}
|
|
1349
|
-
if (!unsubscribe)
|
|
1350
|
-
return void res.end();
|
|
1351
|
-
const heartbeat = setInterval(() => res.write(":hb\n\n"), 25_000);
|
|
1352
|
-
req.on("close", () => {
|
|
1353
|
-
clearInterval(heartbeat);
|
|
1354
|
-
unsubscribe?.();
|
|
1355
|
-
});
|
|
1356
|
-
return;
|
|
1357
|
-
}
|
|
1358
|
-
// POST /api/chats/:id/vibeable {slug | null} — open an app in the chat's preview pane (cwd follows), or close it
|
|
1359
|
-
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "vibeable" && method === "POST") {
|
|
1360
|
-
try {
|
|
1361
|
-
const body = JSON.parse((await readBody(req)) || "{}");
|
|
1362
|
-
const slug = body.slug == null || body.slug === "" ? null : String(body.slug);
|
|
1363
|
-
const result = await setChatVibeable(seg[2], slug);
|
|
1364
|
-
return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
|
|
1365
|
-
}
|
|
1366
|
-
catch (err) {
|
|
1367
|
-
return json(res, { error: err.message }, 400);
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
// POST /api/chats/:id/fork — a new chat continuing a copy of this one's session
|
|
1371
|
-
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "fork" && method === "POST") {
|
|
1372
|
-
try {
|
|
1373
|
-
const result = await forkChat(seg[2]);
|
|
1374
|
-
return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
|
|
1375
|
-
}
|
|
1376
|
-
catch (err) {
|
|
1377
|
-
return json(res, { error: err.message }, 400);
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
// POST /api/chats/:id/attachments — raw file body; x-file-name names it. Returns the stored attachment.
|
|
1381
|
-
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "attachments" && method === "POST") {
|
|
1382
|
-
try {
|
|
1383
|
-
if (!(await getChat(seg[2])))
|
|
1384
|
-
return json(res, { error: "not found" }, 404);
|
|
1385
|
-
const data = await readRawBody(req, 25_000_000);
|
|
1386
|
-
if (data.length === 0)
|
|
1387
|
-
return json(res, { error: "empty file" }, 400);
|
|
1388
|
-
const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
|
|
1389
|
-
const name = await saveAttachment(seg[2], decodeURIComponent(String(req.headers["x-file-name"] ?? "file")), data);
|
|
1390
|
-
return json(res, { name, mimeType, size: data.length });
|
|
1391
|
-
}
|
|
1392
|
-
catch (err) {
|
|
1393
|
-
return json(res, { error: err.message }, 400);
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
// GET /api/chats/:id/attachments/:name — the stored file
|
|
1397
|
-
if (seg.length === 5 && seg[1] === "chats" && seg[3] === "attachments" && method === "GET") {
|
|
1398
|
-
try {
|
|
1399
|
-
const file = attachmentPath(seg[2], seg[4]);
|
|
1400
|
-
const ext = path.extname(file).toLowerCase();
|
|
1401
|
-
const data = await fs.readFile(file);
|
|
1402
|
-
res.writeHead(200, {
|
|
1403
|
-
"content-type": MIME[ext] ?? "application/octet-stream",
|
|
1404
|
-
"content-length": data.length,
|
|
1405
|
-
"cache-control": "private, max-age=86400",
|
|
1406
|
-
"x-content-type-options": "nosniff",
|
|
1407
|
-
});
|
|
1408
|
-
return void res.end(data);
|
|
1409
|
-
}
|
|
1410
|
-
catch {
|
|
1411
|
-
return json(res, { error: "not found" }, 404);
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
// POST /api/chats/:id/reset-session — forget the agent's session; the next message starts fresh
|
|
1415
|
-
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "reset-session" && method === "POST") {
|
|
1416
|
-
try {
|
|
1417
|
-
const result = await resetSession(seg[2]);
|
|
1418
|
-
return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
|
|
1419
|
-
}
|
|
1420
|
-
catch (err) {
|
|
1421
|
-
return json(res, { error: err.message }, 400);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
// POST /api/chats/:id/{message,permission,cancel}
|
|
1425
|
-
if (seg.length === 4 && seg[1] === "chats" && method === "POST") {
|
|
1426
|
-
let body = {};
|
|
1427
|
-
try {
|
|
1428
|
-
const raw = await readBody(req);
|
|
1429
|
-
if (raw)
|
|
1430
|
-
body = JSON.parse(raw);
|
|
1431
|
-
}
|
|
1432
|
-
catch {
|
|
1433
|
-
return json(res, { error: "invalid JSON body" }, 400);
|
|
1434
|
-
}
|
|
1435
|
-
try {
|
|
1436
|
-
let result;
|
|
1437
|
-
const attachments = Array.isArray(body.attachments)
|
|
1438
|
-
? body.attachments
|
|
1439
|
-
.filter((a) => a && typeof a.name === "string" && typeof a.mimeType === "string")
|
|
1440
|
-
.map((a) => ({ name: String(a.name), mimeType: String(a.mimeType), size: Number(a.size) || 0 }))
|
|
1441
|
-
: [];
|
|
1442
|
-
if (seg[3] === "message") {
|
|
1443
|
-
const text = String(body.text ?? "").trim();
|
|
1444
|
-
if (!text && attachments.length === 0)
|
|
1445
|
-
return json(res, { error: "text is required" }, 400);
|
|
1446
|
-
// Channels: the poster's display name signs the message.
|
|
1447
|
-
result = await postMessage(seg[2], text || "(see attached)", { from: typeof body.from === "string" ? body.from : undefined, attachments });
|
|
1448
|
-
}
|
|
1449
|
-
else if (seg[3] === "permission") {
|
|
1450
|
-
result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
|
|
1451
|
-
}
|
|
1452
|
-
else if (seg[3] === "cancel") {
|
|
1453
|
-
// Channels: `agent` stops one member; without it every agent in the chat.
|
|
1454
|
-
result = await cancelChat(seg[2], typeof body.agent === "string" && body.agent ? body.agent : undefined);
|
|
1455
|
-
}
|
|
1456
|
-
else if (seg[3] === "steer") {
|
|
1457
|
-
const text = String(body.text ?? "").trim();
|
|
1458
|
-
if (!text && attachments.length === 0)
|
|
1459
|
-
return json(res, { error: "text is required" }, 400);
|
|
1460
|
-
result = await steerChat(seg[2], text || "(see attached)", attachments);
|
|
1461
|
-
}
|
|
1462
|
-
else if (seg[3] === "elicitation") {
|
|
1463
|
-
const action = body.action === "accept" || body.action === "decline" || body.action === "cancel" ? body.action : null;
|
|
1464
|
-
if (!action)
|
|
1465
|
-
return json(res, { error: "action must be accept, decline, or cancel" }, 400);
|
|
1466
|
-
const content = body.content && typeof body.content === "object" ? body.content : {};
|
|
1467
|
-
result = await answerElicitation(seg[2], String(body.requestId ?? ""), action === "accept" ? { action, content } : { action });
|
|
1468
|
-
}
|
|
1469
|
-
else if (seg[3] === "task-stop") {
|
|
1470
|
-
result = await stopChatTask(seg[2], String(body.taskId ?? ""));
|
|
1471
|
-
}
|
|
1472
|
-
else if (seg[3] === "config") {
|
|
1473
|
-
const value = typeof body.value === "boolean" ? body.value : String(body.value ?? "");
|
|
1474
|
-
result = await setChatConfig(seg[2], String(body.configId ?? ""), value);
|
|
1475
|
-
}
|
|
1476
|
-
else {
|
|
1477
|
-
return json(res, { error: "not found" }, 404);
|
|
1478
|
-
}
|
|
1479
|
-
return result.error ? json(res, result, 409) : json(res, { ok: true });
|
|
1480
|
-
}
|
|
1481
|
-
catch (err) {
|
|
1482
|
-
return json(res, { error: err.message }, 400);
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
json(res, { error: "not found" }, 404);
|
|
1486
|
-
}
|
|
1487
135
|
async function serveStatic(res, staticDir, pathname) {
|
|
1488
136
|
// SPA: unknown paths fall back to index.html (routing is client-side).
|
|
1489
137
|
const rel = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
@@ -1508,15 +156,16 @@ async function serveStatic(res, staticDir, pathname) {
|
|
|
1508
156
|
}
|
|
1509
157
|
/** Start the server; resolves once it listens. Runs until the process ends. */
|
|
1510
158
|
export async function startInspector(opts) {
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
setProjectRoot(opts.projectRoot);
|
|
159
|
+
// Every request, timer and listener below runs in this workspace (see workspace.ts).
|
|
160
|
+
const ws = setDefaultWorkspace(new Workspace({ outputDir: opts.outputDir, root: opts.projectRoot }));
|
|
1514
161
|
// Read once: like the port, the public hostname takes effect on restart.
|
|
1515
|
-
const project = await resolveProject(
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
162
|
+
const project = await resolveProject(ws.root).catch(() => null);
|
|
163
|
+
// This server's own: several servers in one process each guard their own workspace.
|
|
164
|
+
const gate = { publicHost: (project && publicHostFor(project)) ?? "", access: project ? tunnelFor(project)?.access : undefined };
|
|
165
|
+
ws.run(() => {
|
|
166
|
+
startRoutineScheduler();
|
|
167
|
+
startGitSync();
|
|
168
|
+
});
|
|
1520
169
|
// Chat agent subprocesses must die with the server — signals bypass
|
|
1521
170
|
// "exit" handlers, so hook the signals themselves.
|
|
1522
171
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
@@ -1537,16 +186,22 @@ export async function startInspector(opts) {
|
|
|
1537
186
|
if (code !== RESTART_EXIT_CODE)
|
|
1538
187
|
markWorkspaceStopped();
|
|
1539
188
|
});
|
|
1540
|
-
const server = http.createServer(
|
|
189
|
+
const server = http.createServer((req, res) => ws.run(() => serve(req, res)));
|
|
190
|
+
const serve = async (req, res) => {
|
|
1541
191
|
try {
|
|
1542
192
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1543
|
-
if (!hostAllowed(req))
|
|
193
|
+
if (!hostAllowed(req, gate))
|
|
1544
194
|
return json(res, { error: "unexpected Host header" }, 421);
|
|
1545
|
-
const refusal = await accessRefusal(req);
|
|
195
|
+
const refusal = await accessRefusal(req, gate);
|
|
1546
196
|
if (refusal)
|
|
1547
197
|
return json(res, { error: refusal }, 401);
|
|
1548
|
-
if (url.pathname.startsWith("/api/"))
|
|
1549
|
-
|
|
198
|
+
if (url.pathname.startsWith("/api/")) {
|
|
199
|
+
const method = req.method ?? "GET";
|
|
200
|
+
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req))
|
|
201
|
+
json(res, { error: "cross-origin request refused" }, 403);
|
|
202
|
+
else
|
|
203
|
+
await apiRouter.handle(req, res, url);
|
|
204
|
+
}
|
|
1550
205
|
// /vibeables/<slug>/… is an app's own files, served for the preview pane.
|
|
1551
206
|
else if (url.pathname === "/vibeables" || url.pathname.startsWith("/vibeables/"))
|
|
1552
207
|
await serveVibeable(req, res, url);
|
|
@@ -1556,23 +211,34 @@ export async function startInspector(opts) {
|
|
|
1556
211
|
catch (err) {
|
|
1557
212
|
json(res, { error: err.message }, 500);
|
|
1558
213
|
}
|
|
1559
|
-
}
|
|
1560
|
-
// WebSocket upgrades
|
|
214
|
+
};
|
|
215
|
+
// WebSocket upgrades: the control plane's socket (/api/ws), and a vibeable's dev server (HMR).
|
|
1561
216
|
server.on("upgrade", (req, socket, head) => {
|
|
1562
|
-
if (!hostAllowed(req))
|
|
217
|
+
if (!hostAllowed(req, gate))
|
|
1563
218
|
return void socket.destroy();
|
|
1564
|
-
|
|
219
|
+
ws.run(() => accessRefusal(req, gate).then(async (refusal) => {
|
|
220
|
+
if (refusal)
|
|
221
|
+
return void socket.destroy();
|
|
222
|
+
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/api/ws")
|
|
223
|
+
return proxyUpgrade(req, socket, head);
|
|
224
|
+
// A socket is not covered by CORS: a page from another site could open one, so the Origin must be ours.
|
|
225
|
+
if (!sameOrigin(req))
|
|
226
|
+
return void socket.end("HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n");
|
|
227
|
+
const conn = acceptWebSocket(req, socket, head);
|
|
228
|
+
if (conn)
|
|
229
|
+
serveSocket(conn, ws, await getPkgVersion());
|
|
230
|
+
}, () => socket.destroy()));
|
|
1565
231
|
});
|
|
1566
232
|
return new Promise((resolve, reject) => {
|
|
1567
233
|
server.once("error", reject);
|
|
1568
234
|
server.once("close", stopCloudSync);
|
|
1569
|
-
server.listen(opts.port, INSPECTOR_HOST, () => {
|
|
235
|
+
server.listen(opts.port, INSPECTOR_HOST, () => ws.run(() => {
|
|
1570
236
|
const addr = server.address();
|
|
1571
237
|
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
1572
|
-
void registerInstance(port,
|
|
1573
|
-
void registerWorkspace(
|
|
238
|
+
void registerInstance(port, ws.root);
|
|
239
|
+
void registerWorkspace(ws.root);
|
|
1574
240
|
void getPkgVersion().then((v) => startCloudSync(port, v));
|
|
1575
241
|
resolve(server);
|
|
1576
|
-
});
|
|
242
|
+
}));
|
|
1577
243
|
});
|
|
1578
244
|
}
|