@tea-agent/loop-agent 0.16.26 → 0.17.1

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.
Files changed (104) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +55 -7
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +55 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/shared/operator/capabilities.js +255 -0
  16. package/dist/shared/operator/envelope.js +59 -0
  17. package/dist/shared/operator/index.js +4 -0
  18. package/dist/shared/operator/registry.js +38 -0
  19. package/dist/shared/operator/types.js +5 -0
  20. package/dist/task/contract/adopt.js +166 -0
  21. package/dist/task/contract/apply.js +326 -0
  22. package/dist/task/contract/canonicalize.js +60 -0
  23. package/dist/task/contract/constants.js +29 -0
  24. package/dist/task/contract/diff.js +177 -0
  25. package/dist/task/contract/hash.js +42 -0
  26. package/dist/task/contract/import-revision.js +96 -0
  27. package/dist/task/contract/index.js +17 -0
  28. package/dist/task/contract/journal.js +155 -0
  29. package/dist/task/contract/lock.js +153 -0
  30. package/dist/task/contract/observe.js +296 -0
  31. package/dist/task/contract/paths.js +19 -0
  32. package/dist/task/contract/project.js +170 -0
  33. package/dist/task/contract/recover.js +312 -0
  34. package/dist/task/contract/request-ledger.js +37 -0
  35. package/dist/task/contract/schema.js +151 -0
  36. package/dist/task/contract/transaction.js +160 -0
  37. package/dist/task/contract/types.js +1 -0
  38. package/dist/task/contract/validate-draft.js +106 -0
  39. package/dist/task/index.js +3 -0
  40. package/dist/task/operator/capabilities.js +6 -0
  41. package/dist/task/operator/envelope.js +2 -0
  42. package/dist/task/operator/index.js +5 -0
  43. package/dist/task/operator/registry.js +2 -0
  44. package/dist/task/operator/types.js +1 -0
  45. package/dist/task/runtime.js +5 -1
  46. package/dist/task/source-references.js +7 -0
  47. package/dist/worker/cli.js +150 -32
  48. package/dist/worker/console/app-data.js +185 -0
  49. package/dist/worker/console/dag-confirmation.js +313 -0
  50. package/dist/worker/console/doctor.js +169 -0
  51. package/dist/worker/console/draft-store.js +80 -0
  52. package/dist/worker/console/index.js +15 -0
  53. package/dist/worker/console/interview/assessment.js +67 -0
  54. package/dist/worker/console/interview/session.js +100 -0
  55. package/dist/worker/console/interview/tools.js +109 -0
  56. package/dist/worker/console/loopback.js +16 -0
  57. package/dist/worker/console/observe-health-match.js +174 -0
  58. package/dist/worker/console/observe-link.js +33 -0
  59. package/dist/worker/console/operation-runner.js +166 -0
  60. package/dist/worker/console/operation-sse.js +158 -0
  61. package/dist/worker/console/operation-store.js +147 -0
  62. package/dist/worker/console/operator-actions.js +769 -0
  63. package/dist/worker/console/pi-readiness.js +266 -0
  64. package/dist/worker/console/recovery-cta.js +133 -0
  65. package/dist/worker/console/repo-fingerprint.js +29 -0
  66. package/dist/worker/console/resource-loader.js +95 -0
  67. package/dist/worker/console/routes.js +368 -0
  68. package/dist/worker/console/security.js +126 -0
  69. package/dist/worker/console/server.js +149 -0
  70. package/dist/worker/console/sibling-controller.js +28 -0
  71. package/dist/worker/console/static/assets/index-BEIdBogJ.js +9 -0
  72. package/dist/worker/console/static/assets/index-Rt0TqimP.css +1 -0
  73. package/dist/worker/console/static/index.html +13 -0
  74. package/dist/worker/console/vite.config.js +27 -0
  75. package/dist/worker/materialize/harness-task-materializer.js +34 -0
  76. package/dist/worker/observe/health.js +57 -0
  77. package/dist/worker/observe/routes.js +16 -9
  78. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  79. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  80. package/dist/workflows/dag/frontend-repair.js +1 -10
  81. package/dist/workflows/dag/init-hybrid.js +269 -116
  82. package/dist/workflows/dag/node-execution.js +25 -1
  83. package/dist/workflows/dag/retry-policy.js +16 -1
  84. package/dist/workflows/dag/runner.js +9 -0
  85. package/dist/workflows/dag/task-contract-binding.js +138 -0
  86. package/dist/workflows/dag/types.js +74 -10
  87. package/dist/workflows/dag/validate.js +25 -7
  88. package/docs/README.md +2 -0
  89. package/docs/architecture/evolution.md +2 -0
  90. package/docs/architecture/system-overview.md +6 -0
  91. package/docs/architecture/worker-and-feature.md +7 -0
  92. package/docs/templates/agent-dag.schema.json +49 -2
  93. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  94. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  95. package/docs/templates/backend-test-dag.json +26 -154
  96. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  97. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  98. package/package.json +8 -2
  99. package/skills/agent-worker/SKILL.md +1 -0
  100. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  101. package/skills/frontend-design-review/SKILL.md +25 -16
  102. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  103. package/skills/loop-agent/references/command-reference.md +48 -1
  104. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -0,0 +1,368 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { dispatchOperatorAction } from "./operator-actions.js";
5
+ import { openSseResponse, parseLastEventId, writeSseEvent, } from "./operation-sse.js";
6
+ import { repoFingerprintV1 } from "./repo-fingerprint.js";
7
+ import { evaluateMutationGate, injectConfirmationTokenScript, isMutationMethod, } from "./security.js";
8
+ export function sendJson(res, status, body, extraHeaders) {
9
+ const payload = JSON.stringify(body);
10
+ res.writeHead(status, {
11
+ "Content-Type": "application/json; charset=utf-8",
12
+ "Content-Length": Buffer.byteLength(payload),
13
+ ...extraHeaders,
14
+ });
15
+ res.end(payload);
16
+ }
17
+ function contentTypeFor(pathname) {
18
+ const lower = pathname.toLowerCase();
19
+ if (lower.endsWith(".html"))
20
+ return "text/html; charset=utf-8";
21
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) {
22
+ return "text/javascript; charset=utf-8";
23
+ }
24
+ if (lower.endsWith(".css"))
25
+ return "text/css; charset=utf-8";
26
+ if (lower.endsWith(".json"))
27
+ return "application/json; charset=utf-8";
28
+ if (lower.endsWith(".svg"))
29
+ return "image/svg+xml";
30
+ if (lower.endsWith(".png"))
31
+ return "image/png";
32
+ if (lower.endsWith(".ico"))
33
+ return "image/x-icon";
34
+ if (lower.endsWith(".woff2"))
35
+ return "font/woff2";
36
+ return "application/octet-stream";
37
+ }
38
+ async function readJsonBody(req, maxBytes = 10 * 1024 * 1024) {
39
+ const chunks = [];
40
+ let total = 0;
41
+ for await (const chunk of req) {
42
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
43
+ total += buf.length;
44
+ if (total > maxBytes) {
45
+ throw new Error(`request body exceeds ${maxBytes} bytes`);
46
+ }
47
+ chunks.push(buf);
48
+ }
49
+ if (chunks.length === 0)
50
+ return {};
51
+ const raw = Buffer.concat(chunks).toString("utf8");
52
+ if (!raw.trim())
53
+ return {};
54
+ try {
55
+ return JSON.parse(raw);
56
+ }
57
+ catch (error) {
58
+ throw new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`);
59
+ }
60
+ }
61
+ async function handleHealth(_req, res, ctx) {
62
+ let fingerprint;
63
+ try {
64
+ fingerprint = repoFingerprintV1(ctx.repoRoot);
65
+ }
66
+ catch {
67
+ fingerprint = "unavailable";
68
+ }
69
+ const readiness = ctx.operator?.getReadiness();
70
+ sendJson(res, 200, {
71
+ ok: true,
72
+ product: "loop-operator-console",
73
+ productName: "Loop Operator Console",
74
+ repoRoot: ctx.repoRoot,
75
+ repoFingerprint: fingerprint,
76
+ piReadiness: readiness?.state ?? "setup-required",
77
+ generatedAt: new Date().toISOString(),
78
+ });
79
+ }
80
+ async function handleReadiness(_req, res, ctx) {
81
+ const report = ctx.operator?.getReadiness();
82
+ if (!report) {
83
+ sendJson(res, 200, {
84
+ state: "setup-required",
85
+ message: "operator runtime not configured",
86
+ });
87
+ return;
88
+ }
89
+ sendJson(res, 200, report);
90
+ }
91
+ async function handleSessionPing(req, res, ctx) {
92
+ const failure = evaluateMutationGate(req, {
93
+ bootToken: ctx.bootToken.value,
94
+ confirmationToken: ctx.bootToken.confirmationToken,
95
+ consoleOrigin: ctx.getConsoleOrigin(),
96
+ });
97
+ if (failure) {
98
+ sendJson(res, failure.status, {
99
+ ok: false,
100
+ error: { code: failure.code, message: failure.message },
101
+ });
102
+ return;
103
+ }
104
+ sendJson(res, 200, { ok: true, pong: true });
105
+ }
106
+ async function handleCreateOperation(req, res, ctx) {
107
+ if (!ctx.operator) {
108
+ sendJson(res, 503, {
109
+ ok: false,
110
+ error: {
111
+ code: "INTERNAL_ERROR",
112
+ message: "operator runtime unavailable",
113
+ },
114
+ });
115
+ return;
116
+ }
117
+ const failure = evaluateMutationGate(req, {
118
+ bootToken: ctx.bootToken.value,
119
+ confirmationToken: ctx.bootToken.confirmationToken,
120
+ consoleOrigin: ctx.getConsoleOrigin(),
121
+ });
122
+ if (failure) {
123
+ sendJson(res, failure.status, {
124
+ ok: false,
125
+ error: { code: failure.code, message: failure.message },
126
+ });
127
+ return;
128
+ }
129
+ let body;
130
+ try {
131
+ body = (await readJsonBody(req));
132
+ }
133
+ catch (error) {
134
+ sendJson(res, 400, {
135
+ ok: false,
136
+ error: {
137
+ code: "INVALID_INPUT",
138
+ message: error instanceof Error ? error.message : String(error),
139
+ },
140
+ });
141
+ return;
142
+ }
143
+ const action = typeof body.action === "string" ? body.action : "";
144
+ const actionParams = body.actionParams && typeof body.actionParams === "object"
145
+ ? body.actionParams
146
+ : {};
147
+ const clientRequestId = typeof body.clientRequestId === "string" ? body.clientRequestId : undefined;
148
+ const result = await dispatchOperatorAction(ctx.operator.actionContext, {
149
+ action,
150
+ actionParams,
151
+ clientRequestId,
152
+ });
153
+ sendJson(res, result.status, result.body);
154
+ }
155
+ async function handleGetOperation(_req, res, ctx, operationId) {
156
+ if (!ctx.operator) {
157
+ sendJson(res, 503, {
158
+ ok: false,
159
+ error: {
160
+ code: "INTERNAL_ERROR",
161
+ message: "operator runtime unavailable",
162
+ },
163
+ });
164
+ return;
165
+ }
166
+ const op = await ctx.operator.operations.get(operationId);
167
+ if (!op) {
168
+ sendJson(res, 404, {
169
+ ok: false,
170
+ error: {
171
+ code: "NOT_FOUND",
172
+ message: `operation not found: ${operationId}`,
173
+ },
174
+ });
175
+ return;
176
+ }
177
+ sendJson(res, 200, { ok: true, operation: op });
178
+ }
179
+ async function handleOperationEvents(req, res, ctx, operationId) {
180
+ if (!ctx.operator) {
181
+ sendJson(res, 503, {
182
+ ok: false,
183
+ error: {
184
+ code: "INTERNAL_ERROR",
185
+ message: "operator runtime unavailable",
186
+ },
187
+ });
188
+ return;
189
+ }
190
+ const op = await ctx.operator.operations.get(operationId);
191
+ if (!op) {
192
+ sendJson(res, 404, {
193
+ ok: false,
194
+ error: {
195
+ code: "NOT_FOUND",
196
+ message: `operation not found: ${operationId}`,
197
+ },
198
+ });
199
+ return;
200
+ }
201
+ const lastEventId = parseLastEventId(typeof req.headers["last-event-id"] === "string"
202
+ ? req.headers["last-event-id"]
203
+ : undefined);
204
+ const listed = ctx.operator.events.listFrom(operationId, lastEventId);
205
+ if ("error" in listed) {
206
+ sendJson(res, 410, {
207
+ ok: false,
208
+ error: {
209
+ code: "EVENT_CURSOR_EXPIRED",
210
+ message: "event cursor expired; re-read operation snapshot",
211
+ },
212
+ });
213
+ return;
214
+ }
215
+ openSseResponse(res);
216
+ for (const event of listed.events) {
217
+ writeSseEvent(res, event);
218
+ }
219
+ // Keep connection briefly for live events; poll store
220
+ let closed = false;
221
+ req.on("close", () => {
222
+ closed = true;
223
+ });
224
+ const started = Date.now();
225
+ const maxMs = 30_000;
226
+ let cursor = listed.events.length > 0
227
+ ? listed.events[listed.events.length - 1].seq
228
+ : lastEventId;
229
+ while (!closed && Date.now() - started < maxMs) {
230
+ await new Promise((r) => setTimeout(r, 200));
231
+ const more = ctx.operator.events.listFrom(operationId, cursor);
232
+ if ("error" in more) {
233
+ res.write(`event: error\ndata: ${JSON.stringify({ code: "EVENT_CURSOR_EXPIRED" })}\n\n`);
234
+ break;
235
+ }
236
+ for (const event of more.events) {
237
+ writeSseEvent(res, event);
238
+ cursor = event.seq;
239
+ }
240
+ const current = await ctx.operator.operations.get(operationId);
241
+ if (current &&
242
+ (current.state === "succeeded" ||
243
+ current.state === "failed" ||
244
+ current.state === "timed-out" ||
245
+ current.state === "needs-reconcile")) {
246
+ // flush any final events then end
247
+ const final = ctx.operator.events.listFrom(operationId, cursor);
248
+ if (!("error" in final)) {
249
+ for (const event of final.events) {
250
+ writeSseEvent(res, event);
251
+ }
252
+ }
253
+ break;
254
+ }
255
+ }
256
+ res.end();
257
+ }
258
+ /**
259
+ * Path-escape-safe static file serve for Console SPA.
260
+ * Sets boot capability cookie on HTML responses.
261
+ */
262
+ export async function serveConsoleStatic(req, res, ctx) {
263
+ const url = new URL(req.url ?? "/", "http://localhost");
264
+ let pathname = decodeURIComponent(url.pathname);
265
+ if (pathname === "/") {
266
+ pathname = "/index.html";
267
+ }
268
+ const filePath = path.join(ctx.staticDir, pathname);
269
+ const resolvedStatic = path.resolve(ctx.staticDir);
270
+ const resolvedFile = path.resolve(filePath);
271
+ if (!resolvedFile.startsWith(resolvedStatic + path.sep) &&
272
+ resolvedFile !== resolvedStatic) {
273
+ sendJson(res, 404, { error: "Not found" });
274
+ return;
275
+ }
276
+ if (!existsSync(resolvedFile)) {
277
+ // SPA fallback for non-file routes
278
+ const indexPath = path.join(resolvedStatic, "index.html");
279
+ if (existsSync(indexPath) && !pathname.includes(".")) {
280
+ const html = injectConfirmationTokenScript(await readFile(indexPath), ctx.bootToken.confirmationToken);
281
+ res.writeHead(200, {
282
+ "Content-Type": "text/html; charset=utf-8",
283
+ "Content-Length": html.byteLength,
284
+ "Set-Cookie": ctx.bootToken.setCookieHeader,
285
+ });
286
+ res.end(html);
287
+ return;
288
+ }
289
+ sendJson(res, 404, { error: "Not found" });
290
+ return;
291
+ }
292
+ const raw = await readFile(resolvedFile);
293
+ const contentType = contentTypeFor(pathname);
294
+ const content = contentType.startsWith("text/html")
295
+ ? injectConfirmationTokenScript(raw, ctx.bootToken.confirmationToken)
296
+ : raw;
297
+ const headers = {
298
+ "Content-Type": contentType,
299
+ "Content-Length": content.byteLength,
300
+ };
301
+ if (contentType.startsWith("text/html")) {
302
+ headers["Set-Cookie"] = ctx.bootToken.setCookieHeader;
303
+ }
304
+ res.writeHead(200, headers);
305
+ res.end(content);
306
+ }
307
+ export async function handleConsoleRequest(req, res, ctx) {
308
+ const method = (req.method ?? "GET").toUpperCase();
309
+ const url = new URL(req.url ?? "/", "http://localhost");
310
+ const pathname = decodeURIComponent(url.pathname);
311
+ if (method === "GET" && pathname === "/api/health") {
312
+ await handleHealth(req, res, ctx);
313
+ return true;
314
+ }
315
+ if (method === "GET" && pathname === "/api/operator/v1/readiness") {
316
+ await handleReadiness(req, res, ctx);
317
+ return true;
318
+ }
319
+ // Phase 1 mutation stub for security tests
320
+ if (method === "POST" && pathname === "/api/session/ping") {
321
+ await handleSessionPing(req, res, ctx);
322
+ return true;
323
+ }
324
+ if (method === "POST" && pathname === "/api/operator/v1/operations") {
325
+ await handleCreateOperation(req, res, ctx);
326
+ return true;
327
+ }
328
+ const opMatch = pathname.match(/^\/api\/operator\/v1\/operations\/([^/]+)(?:\/(events))?$/);
329
+ if (opMatch) {
330
+ const operationId = decodeURIComponent(opMatch[1]);
331
+ if (method === "GET" && opMatch[2] === "events") {
332
+ await handleOperationEvents(req, res, ctx, operationId);
333
+ return true;
334
+ }
335
+ if (method === "GET") {
336
+ await handleGetOperation(req, res, ctx, operationId);
337
+ return true;
338
+ }
339
+ }
340
+ if (isMutationMethod(method)) {
341
+ const failure = evaluateMutationGate(req, {
342
+ bootToken: ctx.bootToken.value,
343
+ confirmationToken: ctx.bootToken.confirmationToken,
344
+ consoleOrigin: ctx.getConsoleOrigin(),
345
+ });
346
+ if (failure) {
347
+ sendJson(res, failure.status, {
348
+ ok: false,
349
+ error: { code: failure.code, message: failure.message },
350
+ });
351
+ return true;
352
+ }
353
+ // Allow other operator POSTs only under known routes; unknown → 404
354
+ sendJson(res, 404, {
355
+ ok: false,
356
+ error: {
357
+ code: "NOT_FOUND",
358
+ message: `unknown mutation route: ${pathname}`,
359
+ },
360
+ });
361
+ return true;
362
+ }
363
+ if (pathname.startsWith("/api/")) {
364
+ sendJson(res, 404, { error: "Not found" });
365
+ return true;
366
+ }
367
+ return false;
368
+ }
@@ -0,0 +1,126 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { isLoopbackHost } from "./loopback.js";
3
+ export const BOOT_CAPABILITY_COOKIE = "loop_console_capability";
4
+ /** Custom mutation confirmation header (design §9). JS-readable; not the HttpOnly boot cookie. */
5
+ export const CONFIRMATION_TOKEN_HEADER = "x-loop-console-confirmation";
6
+ /** Issue per-process unguessable boot capability + confirmation tokens. */
7
+ export function issueBootCapabilityToken() {
8
+ const value = randomBytes(32).toString("base64url");
9
+ const confirmationToken = randomBytes(32).toString("base64url");
10
+ const setCookieHeader = [
11
+ `${BOOT_CAPABILITY_COOKIE}=${value}`,
12
+ "HttpOnly",
13
+ "SameSite=Strict",
14
+ "Path=/",
15
+ ].join("; ");
16
+ return { value, setCookieHeader, confirmationToken };
17
+ }
18
+ /** Inject confirmation token into HTML so the SPA can send the mutation header. */
19
+ export function injectConfirmationTokenScript(html, confirmationToken) {
20
+ const source = typeof html === "string" ? html : html.toString("utf8");
21
+ const script = `<script>window.__CONSOLE_CONFIRMATION_TOKEN__=${JSON.stringify(confirmationToken)};</script>`;
22
+ if (source.includes("</head>")) {
23
+ return Buffer.from(source.replace("</head>", `${script}</head>`), "utf8");
24
+ }
25
+ if (source.includes("</body>")) {
26
+ return Buffer.from(source.replace("</body>", `${script}</body>`), "utf8");
27
+ }
28
+ return Buffer.from(`${source}\n${script}`, "utf8");
29
+ }
30
+ export function parseCookieHeader(header, name) {
31
+ if (!header)
32
+ return undefined;
33
+ for (const part of header.split(";")) {
34
+ const trimmed = part.trim();
35
+ const eq = trimmed.indexOf("=");
36
+ if (eq <= 0)
37
+ continue;
38
+ const key = trimmed.slice(0, eq).trim();
39
+ if (key === name) {
40
+ return trimmed.slice(eq + 1).trim();
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ function safeEqualToken(a, b) {
46
+ const left = Buffer.from(a);
47
+ const right = Buffer.from(b);
48
+ if (left.length !== right.length)
49
+ return false;
50
+ return timingSafeEqual(left, right);
51
+ }
52
+ /**
53
+ * Minimum mutation challenge (design §9):
54
+ * cookie boot token AND custom confirmation header/token + Host loopback +
55
+ * Origin matches Console + Sec-Fetch-Site not cross-site.
56
+ */
57
+ export function evaluateMutationGate(req, ctx) {
58
+ const cookieToken = parseCookieHeader(req.headers.cookie, BOOT_CAPABILITY_COOKIE);
59
+ if (!cookieToken) {
60
+ return {
61
+ status: 403,
62
+ code: "missing-capability-token",
63
+ message: "mutation requires boot capability cookie",
64
+ };
65
+ }
66
+ if (!safeEqualToken(cookieToken, ctx.bootToken)) {
67
+ return {
68
+ status: 403,
69
+ code: "invalid-capability-token",
70
+ message: "invalid boot capability token",
71
+ };
72
+ }
73
+ const headerRaw = req.headers[CONFIRMATION_TOKEN_HEADER];
74
+ const confirmationHeader = typeof headerRaw === "string"
75
+ ? headerRaw.trim()
76
+ : Array.isArray(headerRaw)
77
+ ? (headerRaw[0] ?? "").trim()
78
+ : "";
79
+ if (!confirmationHeader) {
80
+ return {
81
+ status: 403,
82
+ code: "missing-confirmation-token",
83
+ message: "mutation requires confirmation header token",
84
+ };
85
+ }
86
+ if (!safeEqualToken(confirmationHeader, ctx.confirmationToken)) {
87
+ return {
88
+ status: 403,
89
+ code: "invalid-confirmation-token",
90
+ message: "invalid confirmation header token",
91
+ };
92
+ }
93
+ const hostHeader = req.headers.host ?? "";
94
+ const hostOnly = hostHeader.split(":")[0] ?? "";
95
+ if (!isLoopbackHost(hostOnly)) {
96
+ return {
97
+ status: 403,
98
+ code: "host-not-loopback",
99
+ message: `Host is not loopback: ${hostHeader}`,
100
+ };
101
+ }
102
+ const origin = req.headers.origin;
103
+ if (typeof origin !== "string" ||
104
+ origin.length === 0 ||
105
+ origin !== ctx.consoleOrigin) {
106
+ return {
107
+ status: 403,
108
+ code: "origin-mismatch",
109
+ message: "Origin does not match Console origin",
110
+ };
111
+ }
112
+ const secFetchSite = req.headers["sec-fetch-site"];
113
+ if (typeof secFetchSite === "string" &&
114
+ secFetchSite.toLowerCase() === "cross-site") {
115
+ return {
116
+ status: 403,
117
+ code: "cross-site-fetch",
118
+ message: "Sec-Fetch-Site cross-site is not allowed for mutations",
119
+ };
120
+ }
121
+ return undefined;
122
+ }
123
+ export function isMutationMethod(method) {
124
+ const m = (method ?? "GET").toUpperCase();
125
+ return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
126
+ }
@@ -0,0 +1,149 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
6
+ import { openConsoleAppData } from "./app-data.js";
7
+ import { DagConfirmationStore } from "./dag-confirmation.js";
8
+ import { DraftStore } from "./draft-store.js";
9
+ import { AssessmentStore } from "./interview/assessment.js";
10
+ import { InterviewSessionStore } from "./interview/session.js";
11
+ import { isLoopbackHost } from "./loopback.js";
12
+ import { createOperationEventStore, } from "./operation-sse.js";
13
+ import { OperationStore } from "./operation-store.js";
14
+ import { probePiReadiness, } from "./pi-readiness.js";
15
+ import { handleConsoleRequest, serveConsoleStatic, } from "./routes.js";
16
+ import { issueBootCapabilityToken } from "./security.js";
17
+ import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
18
+ /** Built static assets live next to the compiled server under dist/worker/console/static. */
19
+ export function defaultConsoleStaticDir(fromFileUrl = import.meta.url) {
20
+ return path.join(path.dirname(fileURLToPath(fromFileUrl)), "static");
21
+ }
22
+ /**
23
+ * Create Loop Operator Console HTTP server.
24
+ * Non-loopback host throws before listen (fail closed).
25
+ */
26
+ export async function createConsoleServer(options) {
27
+ const host = options.host ?? "127.0.0.1";
28
+ if (!isLoopbackHost(host)) {
29
+ throw new Error(`console serve: non-loopback host "${host}" is not allowed (fail closed; use 127.0.0.1 or localhost)`);
30
+ }
31
+ const repoRoot = path.resolve(options.repoRoot);
32
+ const port = options.port ?? 8790;
33
+ const staticDir = options.staticDir ?? defaultConsoleStaticDir();
34
+ const bootToken = issueBootCapabilityToken();
35
+ const operatorSessionId = `sess_${randomBytes(16).toString("hex")}`;
36
+ const appData = openConsoleAppData({
37
+ repoRoot,
38
+ appDataRoot: options.appDataRoot,
39
+ });
40
+ const operations = new OperationStore(appData);
41
+ const events = createOperationEventStore({
42
+ persistenceDir: path.join(appData.operations, "events"),
43
+ });
44
+ const drafts = new DraftStore(appData);
45
+ const confirmations = new DagConfirmationStore(appData);
46
+ const interviews = new InterviewSessionStore(appData);
47
+ const assessments = new AssessmentStore(appData);
48
+ const readiness = await probePiReadiness({
49
+ override: options.piReadinessProbe,
50
+ });
51
+ const artifactRoot = path.join(appData.root, "client-artifacts", appData.fingerprint);
52
+ const client = options.createClient?.(artifactRoot) ??
53
+ new LoopAgentClient({
54
+ loopAgentBin: resolveSiblingLoopAgentBin(),
55
+ artifactRoot,
56
+ resolveIdentity: true,
57
+ });
58
+ if (!options.skipReconcile) {
59
+ await operations.reconcileOnBoot();
60
+ }
61
+ const getReadiness = () => readiness;
62
+ const actionContext = {
63
+ repoRoot,
64
+ appData,
65
+ operatorSessionId,
66
+ client,
67
+ operations,
68
+ events,
69
+ drafts,
70
+ confirmations,
71
+ interviews,
72
+ assessments,
73
+ readiness,
74
+ getReadiness,
75
+ observeBaseUrl: options.observeBaseUrl,
76
+ fetchImpl: options.fetchImpl,
77
+ };
78
+ let boundHost = host;
79
+ let boundPort = port;
80
+ const getConsoleOrigin = () => `http://${boundHost}:${boundPort}`;
81
+ const routeContext = {
82
+ repoRoot,
83
+ staticDir,
84
+ bootToken,
85
+ getConsoleOrigin,
86
+ operator: {
87
+ actionContext,
88
+ operations,
89
+ events,
90
+ getReadiness,
91
+ },
92
+ };
93
+ return new Promise((resolve, reject) => {
94
+ const server = createServer((req, res) => {
95
+ void dispatch(req, res).catch((error) => {
96
+ if (!res.headersSent) {
97
+ const payload = JSON.stringify({
98
+ error: error instanceof Error ? error.message : String(error),
99
+ });
100
+ res.writeHead(500, {
101
+ "Content-Type": "application/json",
102
+ "Content-Length": Buffer.byteLength(payload),
103
+ });
104
+ res.end(payload);
105
+ }
106
+ });
107
+ });
108
+ async function dispatch(req, res) {
109
+ // Refresh readiness pointer on each request surface
110
+ actionContext.readiness = getReadiness();
111
+ const handled = await handleConsoleRequest(req, res, routeContext);
112
+ if (!handled) {
113
+ await serveConsoleStatic(req, res, routeContext);
114
+ }
115
+ }
116
+ server.on("error", reject);
117
+ server.listen(port, host, () => {
118
+ const addr = server.address();
119
+ if (!addr || typeof addr === "string") {
120
+ reject(new Error("Failed to get console server address"));
121
+ return;
122
+ }
123
+ boundHost = host;
124
+ boundPort = addr.port;
125
+ const url = `http://${host}:${addr.port}`;
126
+ process.stderr.write(`[console] Loop Operator Console listening on ${url} (loopback only)\n`);
127
+ resolve({
128
+ url,
129
+ host,
130
+ port: addr.port,
131
+ appData,
132
+ operations,
133
+ events,
134
+ getReadiness,
135
+ operatorSessionId,
136
+ close: () => new Promise((closeResolve, closeReject) => {
137
+ server.close((err) => (err ? closeReject(err) : closeResolve()));
138
+ }),
139
+ });
140
+ });
141
+ });
142
+ }
143
+ /** Test helper: replace readiness report in-process. */
144
+ export function setConsoleReadinessForTests(server, report) {
145
+ if (!server.getReadiness)
146
+ return;
147
+ // Mutate via closure replacement is not available; tests should inject probe at create.
148
+ void report;
149
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { findPackageRoot } from "../../shared/package-metadata.js";
5
+ /**
6
+ * Resolve the absolute sibling `bin/loop-agent.js` for this package install.
7
+ * Never selects a controller via PATH.
8
+ */
9
+ export function resolveSiblingLoopAgentBin(fromFileUrl = import.meta.url) {
10
+ const startDir = path.dirname(fileURLToPath(fromFileUrl));
11
+ const packageRoot = findPackageRoot(startDir);
12
+ if (!packageRoot) {
13
+ throw new Error("console sibling controller: cannot locate package root from console module");
14
+ }
15
+ const candidate = path.join(packageRoot, "bin", "loop-agent.js");
16
+ if (!existsSync(candidate)) {
17
+ throw new Error(`console sibling controller: missing sibling bin at ${candidate}`);
18
+ }
19
+ return realpathSync(candidate);
20
+ }
21
+ export function resolvePackageRootFromConsole(fromFileUrl = import.meta.url) {
22
+ const startDir = path.dirname(fileURLToPath(fromFileUrl));
23
+ const packageRoot = findPackageRoot(startDir);
24
+ if (!packageRoot) {
25
+ throw new Error("console: cannot locate package root");
26
+ }
27
+ return packageRoot;
28
+ }