auriga-cli 1.15.2 → 1.16.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/README.md +12 -0
- package/README.zh-CN.md +12 -0
- package/dist/api-types.d.ts +115 -0
- package/dist/api-types.js +4 -0
- package/dist/apply-handlers.d.ts +17 -0
- package/dist/apply-handlers.js +186 -0
- package/dist/catalog.json +1 -1
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +220 -0
- package/dist/help.js +2 -0
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.js +89 -0
- package/dist/plugins.d.ts +29 -0
- package/dist/plugins.js +137 -6
- package/dist/scan-catalog.d.ts +2 -0
- package/dist/scan-catalog.js +138 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.js +759 -0
- package/dist/skills.d.ts +29 -0
- package/dist/skills.js +145 -2
- package/dist/state.d.ts +63 -0
- package/dist/state.js +623 -0
- package/dist/ui-fetch.d.ts +29 -0
- package/dist/ui-fetch.js +267 -0
- package/dist/utils.d.ts +22 -0
- package/dist/utils.js +58 -1
- package/dist/workflow.d.ts +22 -0
- package/dist/workflow.js +63 -0
- package/package.json +5 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
// HTTP server for the Web UI (`auriga-cli web-ui`).
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities: token + Origin auth, /api/state scanner endpoint,
|
|
4
|
+
// /api/apply (202 + SSE-progress), /api/progress (SSE with Last-Event-ID
|
|
5
|
+
// resume + 200-event ring buffer), /api/shutdown (graceful drain), and
|
|
6
|
+
// static asset serve from the extracted UI bundle dir.
|
|
7
|
+
//
|
|
8
|
+
// Public contract is anchored in docs/architecture/web-ui.md §4 (server
|
|
9
|
+
// surface), §6 (data flow + types), §7 (errors).
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { buildScanCatalog } from "./scan-catalog.js";
|
|
16
|
+
import { scanState } from "./state.js";
|
|
17
|
+
// Body parsing cap. /api/apply payloads are tiny (an array of item refs);
|
|
18
|
+
// 1 MiB is generously above the largest realistic batch and small enough that
|
|
19
|
+
// abusive clients can't pin memory.
|
|
20
|
+
const MAX_JSON_BODY = 1 * 1024 * 1024;
|
|
21
|
+
// SSE replay cache: keep at least 200 events per job for at least 5 minutes
|
|
22
|
+
// after the job's `all-done` event so reconnecting clients can resume
|
|
23
|
+
// (spec §6.5).
|
|
24
|
+
const SSE_BUFFER_CAP = 200;
|
|
25
|
+
const SSE_JOB_TTL_MS = 5 * 60 * 1000;
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Generic error helpers — bodies are byte-identical for matching status codes
|
|
28
|
+
// so probers can't distinguish *why* auth failed (spec §7 anti-probing).
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const UNAUTHORIZED_BODY = JSON.stringify({ error: "unauthorized" });
|
|
31
|
+
const FORBIDDEN_BODY = JSON.stringify({ error: "forbidden" });
|
|
32
|
+
function sendJson(res, status, body) {
|
|
33
|
+
if (res.headersSent || res.writableEnded)
|
|
34
|
+
return;
|
|
35
|
+
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
|
36
|
+
res.statusCode = status;
|
|
37
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
38
|
+
res.setHeader("cache-control", "no-store");
|
|
39
|
+
res.end(payload);
|
|
40
|
+
}
|
|
41
|
+
function sendUnauthorized(res) {
|
|
42
|
+
sendJson(res, 401, UNAUTHORIZED_BODY);
|
|
43
|
+
}
|
|
44
|
+
function sendForbidden(res) {
|
|
45
|
+
sendJson(res, 403, FORBIDDEN_BODY);
|
|
46
|
+
}
|
|
47
|
+
function send404(res) {
|
|
48
|
+
// Generic 404 for unknown routes / missing static assets. Body is JSON for
|
|
49
|
+
// consistency with the other error surfaces; tests only assert the status.
|
|
50
|
+
sendJson(res, 404, { error: "not-found" });
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Token: timing-safe constant-time compare on equal-length byte buffers.
|
|
54
|
+
// Returns false fast on length mismatch (length is not secret).
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function tokensEqual(a, b) {
|
|
57
|
+
if (typeof a !== "string" || typeof b !== "string")
|
|
58
|
+
return false;
|
|
59
|
+
if (a.length !== b.length)
|
|
60
|
+
return false;
|
|
61
|
+
if (a.length === 0)
|
|
62
|
+
return false;
|
|
63
|
+
const ab = Buffer.from(a, "utf8");
|
|
64
|
+
const bb = Buffer.from(b, "utf8");
|
|
65
|
+
// Buffer.from on a string always returns a buffer of byte-length matching
|
|
66
|
+
// utf8 encoding; for our hex token shape, byte length == char length, but
|
|
67
|
+
// even with multibyte inputs the length check above + the equal-length
|
|
68
|
+
// guard below keep this safe.
|
|
69
|
+
if (ab.length !== bb.length)
|
|
70
|
+
return false;
|
|
71
|
+
return timingSafeEqual(ab, bb);
|
|
72
|
+
}
|
|
73
|
+
// Extracts token from Authorization header. Returns null if not a well-formed
|
|
74
|
+
// `Bearer <value>` (single space, non-empty value). Strict by design — spec
|
|
75
|
+
// A3 says the canonical shape is "Bearer <token>".
|
|
76
|
+
function parseBearer(authHeader) {
|
|
77
|
+
if (!authHeader)
|
|
78
|
+
return null;
|
|
79
|
+
// Reject any internal whitespace beyond the single delimiter between scheme
|
|
80
|
+
// and value. `Bearer token` (double space) → reject.
|
|
81
|
+
const m = /^Bearer ([^\s]+)$/.exec(authHeader);
|
|
82
|
+
if (!m)
|
|
83
|
+
return null;
|
|
84
|
+
return m[1] ?? null;
|
|
85
|
+
}
|
|
86
|
+
// Extracts ?token=... from the URL search string. URL is parsed against a
|
|
87
|
+
// dummy base so we can reuse the WHATWG parser without needing the real host.
|
|
88
|
+
function parseQueryToken(reqUrl) {
|
|
89
|
+
if (!reqUrl)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const u = new URL(reqUrl, "http://localhost");
|
|
93
|
+
const t = u.searchParams.get("token");
|
|
94
|
+
return t === null || t === "" ? null : t;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Host / Origin whitelist.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function buildAllowedHosts(port) {
|
|
104
|
+
// Stored lowercased; comparisons normalize the incoming header the same way.
|
|
105
|
+
return new Set([
|
|
106
|
+
`127.0.0.1:${port}`,
|
|
107
|
+
`localhost:${port}`,
|
|
108
|
+
`[::1]:${port}`,
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
function isHostAllowed(hostHeader, allowed) {
|
|
112
|
+
if (!hostHeader) {
|
|
113
|
+
// HTTP/1.1 requires Host; if absent, treat as bad. (HTTP/1.0 callers don't
|
|
114
|
+
// exist in this app's threat model.)
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return allowed.has(hostHeader.toLowerCase());
|
|
118
|
+
}
|
|
119
|
+
function isOriginAllowed(originHeader, allowed) {
|
|
120
|
+
if (originHeader === undefined)
|
|
121
|
+
return true; // missing Origin = programmatic / file://
|
|
122
|
+
if (originHeader === "null")
|
|
123
|
+
return true; // file:// origin sends "null"
|
|
124
|
+
// Strip scheme. Only http://host:port is allowed (no https on loopback).
|
|
125
|
+
const m = /^https?:\/\/(.+)$/i.exec(originHeader);
|
|
126
|
+
if (!m)
|
|
127
|
+
return false;
|
|
128
|
+
return allowed.has(m[1].toLowerCase());
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Request URL: parse path + search without depending on host header (which
|
|
132
|
+
// may be hostile — Host is validated separately).
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
function parseRequestUrl(reqUrl) {
|
|
135
|
+
if (!reqUrl)
|
|
136
|
+
return { pathname: "/", searchParams: new URLSearchParams() };
|
|
137
|
+
const u = new URL(reqUrl, "http://localhost");
|
|
138
|
+
return { pathname: u.pathname, searchParams: u.searchParams };
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Body reader with a hard cap so malicious clients can't OOM the server by
|
|
142
|
+
// streaming forever. Resolves with the buffered string or rejects.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
function readJsonBody(req) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
let total = 0;
|
|
147
|
+
const chunks = [];
|
|
148
|
+
req.on("data", (chunk) => {
|
|
149
|
+
total += chunk.length;
|
|
150
|
+
if (total > MAX_JSON_BODY) {
|
|
151
|
+
reject(new Error("body-too-large"));
|
|
152
|
+
req.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.push(chunk);
|
|
156
|
+
});
|
|
157
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
158
|
+
req.on("error", reject);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// ApplyRequest body validation. Returns null if invalid. Validates only the
|
|
163
|
+
// shape we promise to consumers — Slice C / the apply runner may add deeper
|
|
164
|
+
// checks (e.g. name ∈ catalog) later.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
const VALID_CATEGORIES = new Set([
|
|
167
|
+
"workflow",
|
|
168
|
+
"skill",
|
|
169
|
+
"recommended-skill",
|
|
170
|
+
"plugin",
|
|
171
|
+
"hook",
|
|
172
|
+
]);
|
|
173
|
+
const VALID_ACTIONS = new Set(["install", "update", "uninstall"]);
|
|
174
|
+
const VALID_SCOPES = new Set(["project", "user"]);
|
|
175
|
+
const VALID_LANGS = new Set(["en", "zh-CN"]);
|
|
176
|
+
function parseApplyRequest(raw) {
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = JSON.parse(raw);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
if (!parsed || typeof parsed !== "object")
|
|
185
|
+
return null;
|
|
186
|
+
const items = parsed.items;
|
|
187
|
+
if (!Array.isArray(items))
|
|
188
|
+
return null;
|
|
189
|
+
for (const it of items) {
|
|
190
|
+
if (!it || typeof it !== "object")
|
|
191
|
+
return null;
|
|
192
|
+
const { category, name, action, scope, lang } = it;
|
|
193
|
+
if (typeof category !== "string" || !VALID_CATEGORIES.has(category)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (typeof name !== "string" || name.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
if (typeof action !== "string" || !VALID_ACTIONS.has(action))
|
|
199
|
+
return null;
|
|
200
|
+
// Scope is optional. When present it must be a known value AND must not
|
|
201
|
+
// be paired with `category === "workflow"` (workflow is a single root
|
|
202
|
+
// file with no scope concept).
|
|
203
|
+
if (scope !== undefined) {
|
|
204
|
+
if (typeof scope !== "string" || !VALID_SCOPES.has(scope))
|
|
205
|
+
return null;
|
|
206
|
+
if (category === "workflow")
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
// Lang is optional and only meaningful for category="workflow". Any
|
|
210
|
+
// other pairing is a client bug and we reject loudly.
|
|
211
|
+
if (lang !== undefined) {
|
|
212
|
+
if (typeof lang !== "string" || !VALID_LANGS.has(lang))
|
|
213
|
+
return null;
|
|
214
|
+
if (category !== "workflow")
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return parsed;
|
|
219
|
+
}
|
|
220
|
+
function namesInCatalog(items, catalog) {
|
|
221
|
+
for (const it of items) {
|
|
222
|
+
const set = catalog[it.category];
|
|
223
|
+
if (!set || !set.has(it.name))
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Static asset handler. When `uiDir` is configured, we serve files from
|
|
230
|
+
// there with SPA fallback (any unknown path → index.html). When not
|
|
231
|
+
// configured (tests, no-bundle environments), every request returns 404.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
const MIME_BY_EXT = {
|
|
234
|
+
".html": "text/html; charset=utf-8",
|
|
235
|
+
".js": "application/javascript; charset=utf-8",
|
|
236
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
237
|
+
".css": "text/css; charset=utf-8",
|
|
238
|
+
".svg": "image/svg+xml",
|
|
239
|
+
".png": "image/png",
|
|
240
|
+
".jpg": "image/jpeg",
|
|
241
|
+
".jpeg": "image/jpeg",
|
|
242
|
+
".webp": "image/webp",
|
|
243
|
+
".ico": "image/x-icon",
|
|
244
|
+
".json": "application/json; charset=utf-8",
|
|
245
|
+
".woff": "font/woff",
|
|
246
|
+
".woff2": "font/woff2",
|
|
247
|
+
".ttf": "font/ttf",
|
|
248
|
+
".map": "application/json; charset=utf-8",
|
|
249
|
+
};
|
|
250
|
+
async function handleStatic(pathname, uiDir, res) {
|
|
251
|
+
if (!uiDir) {
|
|
252
|
+
send404(res);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Strip leading `/`. Path traversal defense: resolve and verify the
|
|
256
|
+
// result stays inside uiDir.
|
|
257
|
+
const requested = pathname.replace(/^\/+/, "");
|
|
258
|
+
const target = requested === "" ? "index.html" : requested;
|
|
259
|
+
const resolved = path.resolve(uiDir, target);
|
|
260
|
+
const uiDirResolved = path.resolve(uiDir);
|
|
261
|
+
const isInside = resolved === uiDirResolved ||
|
|
262
|
+
resolved.startsWith(uiDirResolved + path.sep);
|
|
263
|
+
if (!isInside) {
|
|
264
|
+
send404(res);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const content = await readFile(resolved);
|
|
269
|
+
sendFile(res, resolved, content);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// SPA fallback: serve index.html for unknown paths (excluding asset-like
|
|
274
|
+
// extensions). This keeps client-side routing usable without 404 noise.
|
|
275
|
+
if (!/\.[a-z0-9]+$/i.test(target)) {
|
|
276
|
+
try {
|
|
277
|
+
const index = await readFile(path.join(uiDirResolved, "index.html"));
|
|
278
|
+
sendFile(res, "index.html", index);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
/* fall through to 404 */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
send404(res);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function sendFile(res, filePath, body) {
|
|
289
|
+
if (res.headersSent || res.writableEnded)
|
|
290
|
+
return;
|
|
291
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
292
|
+
const type = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
293
|
+
res.statusCode = 200;
|
|
294
|
+
res.setHeader("content-type", type);
|
|
295
|
+
res.setHeader("cache-control", "no-store");
|
|
296
|
+
res.end(body);
|
|
297
|
+
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// startServer
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
export async function startServer(opts) {
|
|
302
|
+
// Lifecycle flags. Mutated only by the close path.
|
|
303
|
+
let closing = false;
|
|
304
|
+
// Track open sockets so close() can forcibly tear down idle keep-alive
|
|
305
|
+
// connections (Node's `server.close` only stops accepting; it waits for
|
|
306
|
+
// open sockets indefinitely otherwise).
|
|
307
|
+
const openSockets = new Set();
|
|
308
|
+
// Heartbeat state. `lastPingAt` is bumped on each POST /api/ping. The
|
|
309
|
+
// interval fires periodically and triggers shutdown if too much time
|
|
310
|
+
// has passed without a ping — implementing "closing the browser closes
|
|
311
|
+
// the server" UX.
|
|
312
|
+
let lastPingAt = Date.now();
|
|
313
|
+
let heartbeatTimer = null;
|
|
314
|
+
// Per-instance helpers that need the port (chosen after listen()).
|
|
315
|
+
let allowedHosts;
|
|
316
|
+
// ---- Apply / SSE state ----
|
|
317
|
+
// Job cache. Keyed by jobId; entries are deleted SSE_JOB_TTL_MS after the
|
|
318
|
+
// job finishes. Late subscribers and Last-Event-ID resume both read from
|
|
319
|
+
// here. `currentJobId` enforces serial execution: a second /api/apply that
|
|
320
|
+
// arrives while another job is in-flight returns 409 (spec §6.4 — installers
|
|
321
|
+
// contend on shared files like settings.json + skills-lock.json).
|
|
322
|
+
const jobs = new Map();
|
|
323
|
+
let currentJobId = null;
|
|
324
|
+
function emit(job, event) {
|
|
325
|
+
const id = String(job.nextId);
|
|
326
|
+
job.nextId++;
|
|
327
|
+
job.events.push({ id, event });
|
|
328
|
+
if (job.events.length > SSE_BUFFER_CAP)
|
|
329
|
+
job.events.shift();
|
|
330
|
+
const frame = `id: ${id}\nevent: progress\ndata: ${JSON.stringify(event)}\n\n`;
|
|
331
|
+
for (const sub of job.subscribers) {
|
|
332
|
+
try {
|
|
333
|
+
if (!sub.writableEnded)
|
|
334
|
+
sub.write(frame);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* subscriber went away mid-write — close listener will remove it */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (event.type === "all-done") {
|
|
341
|
+
job.finished = true;
|
|
342
|
+
// Close all live subscribers — they've received the terminal event.
|
|
343
|
+
for (const sub of job.subscribers) {
|
|
344
|
+
try {
|
|
345
|
+
if (!sub.writableEnded)
|
|
346
|
+
sub.end();
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
/* ignore */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
job.subscribers.clear();
|
|
353
|
+
// Schedule cache eviction. .unref() so we don't keep the process alive.
|
|
354
|
+
job.cleanupTimer = setTimeout(() => {
|
|
355
|
+
jobs.delete(job.jobId);
|
|
356
|
+
}, SSE_JOB_TTL_MS);
|
|
357
|
+
job.cleanupTimer.unref?.();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function runApplyJob(job, items, handlers) {
|
|
361
|
+
let failedCount = 0;
|
|
362
|
+
try {
|
|
363
|
+
for (let i = 0; i < items.length; i++) {
|
|
364
|
+
const item = items[i];
|
|
365
|
+
emit(job, {
|
|
366
|
+
type: "item:start",
|
|
367
|
+
index: i,
|
|
368
|
+
total: items.length,
|
|
369
|
+
item,
|
|
370
|
+
});
|
|
371
|
+
const handler = handlers[item.category];
|
|
372
|
+
try {
|
|
373
|
+
await handler(item.action, item.name, {
|
|
374
|
+
onLog: (line, level) => emit(job, { type: "item:log", index: i, line, level }),
|
|
375
|
+
scope: item.scope,
|
|
376
|
+
lang: item.lang,
|
|
377
|
+
});
|
|
378
|
+
emit(job, { type: "item:done", index: i, success: true });
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
failedCount++;
|
|
382
|
+
const msg = err instanceof Error && err.message ? err.message : "handler-failed";
|
|
383
|
+
emit(job, {
|
|
384
|
+
type: "item:done",
|
|
385
|
+
index: i,
|
|
386
|
+
success: false,
|
|
387
|
+
error: msg,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
// Clear the in-flight slot BEFORE emitting all-done so a client that
|
|
394
|
+
// reacts immediately to the terminal frame can submit a new apply
|
|
395
|
+
// without racing (test "after first job finishes, new apply succeeds").
|
|
396
|
+
if (currentJobId === job.jobId)
|
|
397
|
+
currentJobId = null;
|
|
398
|
+
emit(job, {
|
|
399
|
+
type: "all-done",
|
|
400
|
+
success: failedCount === 0,
|
|
401
|
+
failedCount,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function handleApply(req, res) {
|
|
406
|
+
// 1. Concurrency: serial execution per spec §6.4.
|
|
407
|
+
if (currentJobId !== null) {
|
|
408
|
+
sendJson(res, 409, { error: "apply-in-flight" });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// 2. Body cap + JSON parse + shape validation.
|
|
412
|
+
let raw;
|
|
413
|
+
try {
|
|
414
|
+
raw = await readJsonBody(req);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
sendJson(res, 413, { error: "body-too-large" });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const parsed = parseApplyRequest(raw);
|
|
421
|
+
if (parsed === null) {
|
|
422
|
+
sendJson(res, 400, { error: "bad-request" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// 3. Catalog membership (only when an applyCatalog was injected).
|
|
426
|
+
if (opts.applyCatalog) {
|
|
427
|
+
if (parsed.items.length === 0) {
|
|
428
|
+
sendJson(res, 400, { error: "items-empty" });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!namesInCatalog(parsed.items, opts.applyCatalog)) {
|
|
432
|
+
sendJson(res, 400, { error: "unknown-name" });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// 4. Allocate job. randomBytes(16) → 32 hex chars = 128 bits of entropy.
|
|
437
|
+
const jobId = randomBytes(16).toString("hex");
|
|
438
|
+
const job = {
|
|
439
|
+
jobId,
|
|
440
|
+
events: [],
|
|
441
|
+
nextId: 1,
|
|
442
|
+
finished: false,
|
|
443
|
+
subscribers: new Set(),
|
|
444
|
+
};
|
|
445
|
+
jobs.set(jobId, job);
|
|
446
|
+
currentJobId = jobId;
|
|
447
|
+
// 5. Accept fast — 202 returns BEFORE any handler runs.
|
|
448
|
+
sendJson(res, 202, { jobId });
|
|
449
|
+
// 6. Kick off the worker on the next tick so the response flushes first.
|
|
450
|
+
const handlers = opts.applyHandlers ?? defaultHandlersNotConfigured;
|
|
451
|
+
setImmediate(() => {
|
|
452
|
+
void runApplyJob(job, parsed.items, handlers);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function handleProgress(req, searchParams, res) {
|
|
456
|
+
const jobId = searchParams.get("jobId");
|
|
457
|
+
if (!jobId) {
|
|
458
|
+
sendJson(res, 400, { error: "missing-jobId" });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const job = jobs.get(jobId);
|
|
462
|
+
if (!job) {
|
|
463
|
+
sendJson(res, 404, { error: "unknown-job" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Parse Last-Event-ID. If valid numeric, replay buffered events with
|
|
467
|
+
// id strictly greater than the cursor. Otherwise replay everything in
|
|
468
|
+
// the buffer. EventSource spec sends the value verbatim from the last
|
|
469
|
+
// observed `id:` field.
|
|
470
|
+
const lastEventIdHeader = req.headers["last-event-id"];
|
|
471
|
+
let cursor = -1;
|
|
472
|
+
if (typeof lastEventIdHeader === "string" && lastEventIdHeader.length > 0) {
|
|
473
|
+
const parsedCursor = Number.parseInt(lastEventIdHeader, 10);
|
|
474
|
+
if (Number.isFinite(parsedCursor))
|
|
475
|
+
cursor = parsedCursor;
|
|
476
|
+
}
|
|
477
|
+
res.statusCode = 200;
|
|
478
|
+
res.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
479
|
+
res.setHeader("cache-control", "no-store");
|
|
480
|
+
res.setHeader("connection", "keep-alive");
|
|
481
|
+
res.flushHeaders?.();
|
|
482
|
+
// Replay strictly-newer cached events.
|
|
483
|
+
for (const buffered of job.events) {
|
|
484
|
+
const bid = Number.parseInt(buffered.id, 10);
|
|
485
|
+
if (Number.isFinite(bid) && bid <= cursor)
|
|
486
|
+
continue;
|
|
487
|
+
res.write(`id: ${buffered.id}\nevent: progress\ndata: ${JSON.stringify(buffered.event)}\n\n`);
|
|
488
|
+
}
|
|
489
|
+
if (job.finished) {
|
|
490
|
+
// No more events will arrive; close cleanly so late subscribers and
|
|
491
|
+
// resumers don't hold the socket forever.
|
|
492
|
+
res.end();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Subscribe for live events. Detach on client disconnect.
|
|
496
|
+
job.subscribers.add(res);
|
|
497
|
+
const detach = () => {
|
|
498
|
+
job.subscribers.delete(res);
|
|
499
|
+
};
|
|
500
|
+
req.once("close", detach);
|
|
501
|
+
res.once("close", detach);
|
|
502
|
+
}
|
|
503
|
+
const server = createServer(async (req, res) => {
|
|
504
|
+
try {
|
|
505
|
+
await handleRequest(req, res);
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
// Last-resort safety net. Never echo error details — they may contain
|
|
509
|
+
// upstream library messages we don't control.
|
|
510
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
511
|
+
sendJson(res, 500, { error: "internal" });
|
|
512
|
+
}
|
|
513
|
+
// Log to stderr without surfacing token-bearing context (req.url may
|
|
514
|
+
// include ?token=, so don't print it).
|
|
515
|
+
const safeMsg = err instanceof Error ? err.message.replace(/token=[^&\s]*/gi, "token=***") : String(err);
|
|
516
|
+
// eslint-disable-next-line no-console
|
|
517
|
+
console.error(`[server] handler error: ${safeMsg}`);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
server.on("connection", (socket) => {
|
|
521
|
+
openSockets.add(socket);
|
|
522
|
+
socket.once("close", () => openSockets.delete(socket));
|
|
523
|
+
});
|
|
524
|
+
async function handleRequest(req, res) {
|
|
525
|
+
const { pathname, searchParams } = parseRequestUrl(req.url);
|
|
526
|
+
const hostHeader = req.headers.host;
|
|
527
|
+
const originHeader = req.headers.origin;
|
|
528
|
+
// 1. Host whitelist (DNS-rebinding defense) — applies to ALL paths, even
|
|
529
|
+
// public statics. Without this, an attacker could lure the user to a
|
|
530
|
+
// rebinding-controlled domain and pull JS off `/`.
|
|
531
|
+
if (!isHostAllowed(hostHeader, allowedHosts)) {
|
|
532
|
+
sendForbidden(res);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// 2. Origin whitelist — also applies to all paths.
|
|
536
|
+
if (!isOriginAllowed(originHeader, allowedHosts)) {
|
|
537
|
+
sendForbidden(res);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const isApiPath = pathname.startsWith("/api/");
|
|
541
|
+
// 3. Token (only on /api/*). Static paths are public per spec §6.1.
|
|
542
|
+
if (isApiPath) {
|
|
543
|
+
const headerToken = parseBearer(req.headers.authorization);
|
|
544
|
+
const queryToken = parseQueryToken(req.url);
|
|
545
|
+
// If BOTH sources present, they must agree (defense against smuggling
|
|
546
|
+
// ambiguity per A6).
|
|
547
|
+
if (headerToken !== null && queryToken !== null) {
|
|
548
|
+
if (!tokensEqual(headerToken, queryToken)) {
|
|
549
|
+
sendUnauthorized(res);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const presented = headerToken ?? queryToken;
|
|
554
|
+
if (presented === null) {
|
|
555
|
+
sendUnauthorized(res);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (!tokensEqual(presented, opts.token)) {
|
|
559
|
+
sendUnauthorized(res);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// 4. Routing.
|
|
564
|
+
const method = req.method ?? "GET";
|
|
565
|
+
if (!isApiPath) {
|
|
566
|
+
await handleStatic(pathname, opts.uiDir, res);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (closing && pathname !== "/api/shutdown") {
|
|
570
|
+
// After shutdown is initiated, refuse further work cleanly.
|
|
571
|
+
sendJson(res, 503, { error: "shutting-down" });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (pathname === "/api/catalog" && method === "GET") {
|
|
575
|
+
await routeCatalog(opts.cwd, res);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (pathname === "/api/state" && method === "GET") {
|
|
579
|
+
await routeState(opts.cwd, opts.packageRoot ?? opts.cwd, res);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (pathname === "/api/apply" && method === "POST") {
|
|
583
|
+
await handleApply(req, res);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (pathname === "/api/progress" && method === "GET") {
|
|
587
|
+
handleProgress(req, searchParams, res);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (pathname === "/api/ping" && method === "POST") {
|
|
591
|
+
lastPingAt = Date.now();
|
|
592
|
+
sendJson(res, 200, { ok: true });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (pathname === "/api/shutdown" && method === "POST") {
|
|
596
|
+
sendJson(res, 200, { ok: true });
|
|
597
|
+
// Defer the actual teardown so the response can flush.
|
|
598
|
+
closing = true;
|
|
599
|
+
setImmediate(() => {
|
|
600
|
+
void initiateShutdown();
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
send404(res);
|
|
605
|
+
}
|
|
606
|
+
async function initiateShutdown() {
|
|
607
|
+
closing = true;
|
|
608
|
+
if (heartbeatTimer) {
|
|
609
|
+
clearInterval(heartbeatTimer);
|
|
610
|
+
heartbeatTimer = null;
|
|
611
|
+
}
|
|
612
|
+
// Spec §4.3 / §6.6 — graceful shutdown waits for the in-flight job up to
|
|
613
|
+
// `shutdownGraceMs` (default 30s). Items continue to drive SSE events
|
|
614
|
+
// through to all-done; new /api/apply is already blocked by `closing`.
|
|
615
|
+
const graceMs = opts.shutdownGraceMs ?? 30_000;
|
|
616
|
+
if (currentJobId !== null && graceMs > 0) {
|
|
617
|
+
await new Promise((resolve) => {
|
|
618
|
+
const start = Date.now();
|
|
619
|
+
const timer = setInterval(() => {
|
|
620
|
+
if (currentJobId === null || Date.now() - start >= graceMs) {
|
|
621
|
+
clearInterval(timer);
|
|
622
|
+
resolve();
|
|
623
|
+
}
|
|
624
|
+
}, 50);
|
|
625
|
+
timer.unref?.();
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
// Stop accepting new connections.
|
|
629
|
+
server.close();
|
|
630
|
+
// Force-close open keep-alive sockets so close() can resolve promptly.
|
|
631
|
+
for (const s of openSockets) {
|
|
632
|
+
s.destroy();
|
|
633
|
+
}
|
|
634
|
+
openSockets.clear();
|
|
635
|
+
}
|
|
636
|
+
// Listen.
|
|
637
|
+
await new Promise((resolve, reject) => {
|
|
638
|
+
const onError = (err) => {
|
|
639
|
+
server.off("listening", onListening);
|
|
640
|
+
reject(err);
|
|
641
|
+
};
|
|
642
|
+
const onListening = () => {
|
|
643
|
+
server.off("error", onError);
|
|
644
|
+
resolve();
|
|
645
|
+
};
|
|
646
|
+
server.once("error", onError);
|
|
647
|
+
server.once("listening", onListening);
|
|
648
|
+
server.listen(opts.port ?? 0, "127.0.0.1");
|
|
649
|
+
});
|
|
650
|
+
const address = server.address();
|
|
651
|
+
const port = address?.port ?? opts.port ?? 0;
|
|
652
|
+
allowedHosts = buildAllowedHosts(port);
|
|
653
|
+
// Start heartbeat if enabled. Interval is half the timeout so the worst-
|
|
654
|
+
// case detection latency is ≤ timeout + interval. `.unref()` keeps Node
|
|
655
|
+
// from hanging waiting on this timer if the user kills the process.
|
|
656
|
+
const heartbeatMs = opts.heartbeatTimeoutMs ?? 0;
|
|
657
|
+
if (heartbeatMs > 0) {
|
|
658
|
+
const interval = Math.max(1000, Math.floor(heartbeatMs / 3));
|
|
659
|
+
heartbeatTimer = setInterval(() => {
|
|
660
|
+
if (closing)
|
|
661
|
+
return;
|
|
662
|
+
if (Date.now() - lastPingAt > heartbeatMs) {
|
|
663
|
+
void initiateShutdown();
|
|
664
|
+
}
|
|
665
|
+
}, interval);
|
|
666
|
+
heartbeatTimer.unref();
|
|
667
|
+
}
|
|
668
|
+
// Tracks "fully stopped" — resolved by Node's http server `close` event,
|
|
669
|
+
// which fires on either the explicit close() path or the heartbeat-driven
|
|
670
|
+
// initiateShutdown() path.
|
|
671
|
+
const closed = new Promise((resolve) => {
|
|
672
|
+
server.once("close", () => resolve());
|
|
673
|
+
});
|
|
674
|
+
return {
|
|
675
|
+
port,
|
|
676
|
+
close: async () => {
|
|
677
|
+
closing = true;
|
|
678
|
+
if (heartbeatTimer) {
|
|
679
|
+
clearInterval(heartbeatTimer);
|
|
680
|
+
heartbeatTimer = null;
|
|
681
|
+
}
|
|
682
|
+
// Synchronously break all open sockets so close() resolves quickly
|
|
683
|
+
// (otherwise keep-alive idle conns would block until their timeout).
|
|
684
|
+
for (const s of openSockets) {
|
|
685
|
+
s.destroy();
|
|
686
|
+
}
|
|
687
|
+
openSockets.clear();
|
|
688
|
+
try {
|
|
689
|
+
await new Promise((resolve, reject) => {
|
|
690
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
// `Server.close()` rejects when the server isn't running — happens
|
|
695
|
+
// when the heartbeat path already shut things down. Treat as success.
|
|
696
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
697
|
+
if (!/not running|not listening/i.test(msg))
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
await closed;
|
|
701
|
+
},
|
|
702
|
+
closed,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Route: GET /api/state
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
async function routeState(cwd, packageRoot, res) {
|
|
709
|
+
try {
|
|
710
|
+
const catalog = await buildScanCatalog(packageRoot);
|
|
711
|
+
const report = await scanState(cwd, catalog);
|
|
712
|
+
sendJson(res, 200, report);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// Catalog or scan blew up — return a structured 500 so the UI can show
|
|
716
|
+
// a recovery banner rather than getting an HTML error page.
|
|
717
|
+
sendJson(res, 500, { error: "scan-failed" });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// Route: GET /api/catalog
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
async function routeCatalog(cwd, res) {
|
|
724
|
+
// Read `<cwd>/dist/catalog.json`. The spec (§6.1) says this endpoint
|
|
725
|
+
// returns the current catalog content; if the catalog is missing (e.g.,
|
|
726
|
+
// running from a checkout without `npm run build`), return an empty
|
|
727
|
+
// object so the UI can degrade gracefully rather than 500-ing.
|
|
728
|
+
const catalogPath = path.join(cwd, "dist", "catalog.json");
|
|
729
|
+
let body;
|
|
730
|
+
try {
|
|
731
|
+
body = await readFile(catalogPath, "utf8");
|
|
732
|
+
// Validate it parses as JSON before forwarding; if not, fall back to {}.
|
|
733
|
+
JSON.parse(body);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
body = "{}";
|
|
737
|
+
}
|
|
738
|
+
res.statusCode = 200;
|
|
739
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
740
|
+
res.setHeader("cache-control", "no-store");
|
|
741
|
+
res.end(body);
|
|
742
|
+
}
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// Default apply handlers: returned when callers don't inject their own.
|
|
745
|
+
// CLI mode (M3 T3.6) replaces this with real installers wired via
|
|
746
|
+
// applyHandlers; tests always inject explicit mocks. The fallback throws so
|
|
747
|
+
// any forgotten wiring surfaces immediately as an item:done failure instead
|
|
748
|
+
// of silently no-op'ing.
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
const handlerNotConfigured = async () => {
|
|
751
|
+
throw new Error("apply handlers not configured");
|
|
752
|
+
};
|
|
753
|
+
const defaultHandlersNotConfigured = {
|
|
754
|
+
workflow: handlerNotConfigured,
|
|
755
|
+
skill: handlerNotConfigured,
|
|
756
|
+
"recommended-skill": handlerNotConfigured,
|
|
757
|
+
plugin: handlerNotConfigured,
|
|
758
|
+
hook: handlerNotConfigured,
|
|
759
|
+
};
|