@tea-agent/loop-agent 0.18.0 → 0.19.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/AGENTS.md +2 -2
- package/CHANGELOG.md +29 -73
- package/README.md +4 -6
- package/dist/commands/docs-audit.js +1 -0
- package/dist/commands/init.js +3 -3
- package/dist/governance/exec-plans.js +4 -0
- package/dist/worker/cli.js +13 -12
- package/dist/worker/console/doctor.js +55 -2
- package/dist/worker/console/index.js +1 -0
- package/dist/worker/console/loopback.js +2 -2
- package/dist/worker/console/observe-link.js +7 -2
- package/dist/worker/console/operator-actions.js +30 -2
- package/dist/worker/console/operator-selection.js +92 -0
- package/dist/worker/console/operator-surface-health.js +23 -0
- package/dist/worker/console/recovery-cta.js +2 -2
- package/dist/worker/console/routes.js +45 -19
- package/dist/worker/console/security.js +29 -4
- package/dist/worker/console/server.js +106 -8
- package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
- package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/observe/routes.js +63 -21
- package/dist/worker/observe/server.js +3 -10
- package/dist/worker/observe/static/index.html +6 -3
- package/dist/worker/observe/static/styles.css +53 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +58 -33
- package/dist/workflows/dag/init-hybrid.js +3 -3
- package/docs/README.md +3 -2
- package/docs/architecture/evolution.md +6 -6
- package/docs/architecture/worker-and-feature.md +9 -9
- package/docs/templates/backend-test-dag.json +4 -4
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +2 -2
- package/skills/loop-agent/references/command-reference.md +4 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
- package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
- package/dist/worker/console/static/assets/index-ucIzpaGJ.css +0 -1
|
@@ -3,7 +3,8 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { dispatchOperatorAction } from "./operator-actions.js";
|
|
5
5
|
import { openSseResponse, parseLastEventId, writeSseEvent, } from "./operation-sse.js";
|
|
6
|
-
import {
|
|
6
|
+
import { buildOperatorSurfaceHealthV1 } from "./operator-surface-health.js";
|
|
7
|
+
import { ROUTES } from "../observe/routes.js";
|
|
7
8
|
import { evaluateMutationGate, injectConfirmationTokenScript, isMutationMethod, } from "./security.js";
|
|
8
9
|
export function sendJson(res, status, body, extraHeaders) {
|
|
9
10
|
const payload = JSON.stringify(body);
|
|
@@ -58,24 +59,33 @@ async function readJsonBody(req, maxBytes = 10 * 1024 * 1024) {
|
|
|
58
59
|
throw new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
async function
|
|
62
|
-
let fingerprint;
|
|
62
|
+
export async function handleConsoleHealthRequest(_req, res, ctx) {
|
|
63
63
|
try {
|
|
64
|
-
|
|
64
|
+
sendJson(res, 200, buildOperatorSurfaceHealthV1({
|
|
65
|
+
repoRoot: ctx.repoRoot,
|
|
66
|
+
piReadiness: ctx.operator?.getReadiness().state ?? "setup-required",
|
|
67
|
+
inspectReady: ctx.inspect.ready,
|
|
68
|
+
routes: ROUTES,
|
|
69
|
+
}));
|
|
65
70
|
}
|
|
66
71
|
catch {
|
|
67
|
-
|
|
72
|
+
sendJson(res, 200, {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
ok: true,
|
|
75
|
+
product: "loop-operator-console",
|
|
76
|
+
repoFingerprint: "unavailable",
|
|
77
|
+
packageName: "unknown",
|
|
78
|
+
packageVersion: "0.0.0",
|
|
79
|
+
piReadiness: ctx.operator?.getReadiness().state ?? "setup-required",
|
|
80
|
+
inspect: {
|
|
81
|
+
ready: ctx.inspect.ready,
|
|
82
|
+
routeCapabilities: ctx.inspect.routeCapabilities,
|
|
83
|
+
snapshotAvailable: true,
|
|
84
|
+
eventStreamAvailable: true,
|
|
85
|
+
},
|
|
86
|
+
generatedAt: new Date().toISOString(),
|
|
87
|
+
});
|
|
68
88
|
}
|
|
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
89
|
}
|
|
80
90
|
async function handleReadiness(_req, res, ctx) {
|
|
81
91
|
const report = ctx.operator?.getReadiness();
|
|
@@ -93,6 +103,7 @@ async function handleSessionPing(req, res, ctx) {
|
|
|
93
103
|
bootToken: ctx.bootToken.value,
|
|
94
104
|
confirmationToken: ctx.bootToken.confirmationToken,
|
|
95
105
|
consoleOrigin: ctx.getConsoleOrigin(),
|
|
106
|
+
allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
|
|
96
107
|
});
|
|
97
108
|
if (failure) {
|
|
98
109
|
sendJson(res, failure.status, {
|
|
@@ -118,6 +129,7 @@ async function handleCreateOperation(req, res, ctx) {
|
|
|
118
129
|
bootToken: ctx.bootToken.value,
|
|
119
130
|
confirmationToken: ctx.bootToken.confirmationToken,
|
|
120
131
|
consoleOrigin: ctx.getConsoleOrigin(),
|
|
132
|
+
allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
|
|
121
133
|
});
|
|
122
134
|
if (failure) {
|
|
123
135
|
sendJson(res, failure.status, {
|
|
@@ -304,13 +316,13 @@ export async function serveConsoleStatic(req, res, ctx) {
|
|
|
304
316
|
res.writeHead(200, headers);
|
|
305
317
|
res.end(content);
|
|
306
318
|
}
|
|
307
|
-
export async function
|
|
319
|
+
export async function handleConsoleOperatorRequest(req, res, ctx) {
|
|
308
320
|
const method = (req.method ?? "GET").toUpperCase();
|
|
309
321
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
310
322
|
const pathname = decodeURIComponent(url.pathname);
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
return
|
|
323
|
+
if (!pathname.startsWith("/api/operator/v1/") &&
|
|
324
|
+
!pathname.startsWith("/api/session/")) {
|
|
325
|
+
return false;
|
|
314
326
|
}
|
|
315
327
|
if (method === "GET" && pathname === "/api/operator/v1/readiness") {
|
|
316
328
|
await handleReadiness(req, res, ctx);
|
|
@@ -342,6 +354,7 @@ export async function handleConsoleRequest(req, res, ctx) {
|
|
|
342
354
|
bootToken: ctx.bootToken.value,
|
|
343
355
|
confirmationToken: ctx.bootToken.confirmationToken,
|
|
344
356
|
consoleOrigin: ctx.getConsoleOrigin(),
|
|
357
|
+
allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
|
|
345
358
|
});
|
|
346
359
|
if (failure) {
|
|
347
360
|
sendJson(res, failure.status, {
|
|
@@ -360,6 +373,19 @@ export async function handleConsoleRequest(req, res, ctx) {
|
|
|
360
373
|
});
|
|
361
374
|
return true;
|
|
362
375
|
}
|
|
376
|
+
sendJson(res, 404, { error: "Not found" });
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
export async function handleConsoleRequest(req, res, ctx) {
|
|
380
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
381
|
+
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
382
|
+
if (method === "GET" && pathname === "/api/health") {
|
|
383
|
+
await handleConsoleHealthRequest(req, res, ctx);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const handled = await handleConsoleOperatorRequest(req, res, ctx);
|
|
387
|
+
if (handled)
|
|
388
|
+
return true;
|
|
363
389
|
if (pathname.startsWith("/api/")) {
|
|
364
390
|
sendJson(res, 404, { error: "Not found" });
|
|
365
391
|
return true;
|
|
@@ -51,8 +51,8 @@ function safeEqualToken(a, b) {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Minimum mutation challenge (design §9):
|
|
54
|
-
* cookie boot token AND custom confirmation header/token + Host
|
|
55
|
-
*
|
|
54
|
+
* cookie boot token AND custom confirmation header/token + Host/Origin checks +
|
|
55
|
+
* Sec-Fetch-Site not cross-site.
|
|
56
56
|
*/
|
|
57
57
|
export function evaluateMutationGate(req, ctx) {
|
|
58
58
|
const cookieToken = parseCookieHeader(req.headers.cookie, BOOT_CAPABILITY_COOKIE);
|
|
@@ -92,7 +92,16 @@ export function evaluateMutationGate(req, ctx) {
|
|
|
92
92
|
}
|
|
93
93
|
const hostHeader = req.headers.host ?? "";
|
|
94
94
|
const hostOnly = hostHeader.split(":")[0] ?? "";
|
|
95
|
-
if (
|
|
95
|
+
if (ctx.allowNonLoopbackAccess) {
|
|
96
|
+
if (!hostHeader.trim()) {
|
|
97
|
+
return {
|
|
98
|
+
status: 403,
|
|
99
|
+
code: "host-not-loopback",
|
|
100
|
+
message: "Host header is required for non-loopback Console binds",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (!isLoopbackHost(hostOnly)) {
|
|
96
105
|
return {
|
|
97
106
|
status: 403,
|
|
98
107
|
code: "host-not-loopback",
|
|
@@ -100,7 +109,23 @@ export function evaluateMutationGate(req, ctx) {
|
|
|
100
109
|
};
|
|
101
110
|
}
|
|
102
111
|
const origin = req.headers.origin;
|
|
103
|
-
if (
|
|
112
|
+
if (ctx.allowNonLoopbackAccess) {
|
|
113
|
+
const expectedFromHost = hostHeader.trim()
|
|
114
|
+
? `http://${hostHeader.trim()}`
|
|
115
|
+
: "";
|
|
116
|
+
const originOk = typeof origin === "string" &&
|
|
117
|
+
origin.length > 0 &&
|
|
118
|
+
(origin === ctx.consoleOrigin ||
|
|
119
|
+
(Boolean(expectedFromHost) && origin === expectedFromHost));
|
|
120
|
+
if (!originOk) {
|
|
121
|
+
return {
|
|
122
|
+
status: 403,
|
|
123
|
+
code: "origin-mismatch",
|
|
124
|
+
message: "Origin does not match Console access URL",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (typeof origin !== "string" ||
|
|
104
129
|
origin.length === 0 ||
|
|
105
130
|
origin !== ctx.consoleOrigin) {
|
|
106
131
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,25 +13,35 @@ import { isLoopbackHost } from "./loopback.js";
|
|
|
12
13
|
import { createOperationEventStore, } from "./operation-sse.js";
|
|
13
14
|
import { OperationStore } from "./operation-store.js";
|
|
14
15
|
import { probePiReadiness, } from "./pi-readiness.js";
|
|
15
|
-
import {
|
|
16
|
+
import { handleConsoleHealthRequest, handleConsoleOperatorRequest, sendJson, serveConsoleStatic, } from "./routes.js";
|
|
16
17
|
import { issueBootCapabilityToken } from "./security.js";
|
|
17
18
|
import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
|
|
19
|
+
import { deriveObserveRouteCapabilities } from "../observe/health.js";
|
|
20
|
+
import { createObserveRouteContext, defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
|
|
18
21
|
/** Built static assets live next to the compiled server under dist/worker/console/static. */
|
|
19
22
|
export function defaultConsoleStaticDir(fromFileUrl = import.meta.url) {
|
|
20
23
|
return path.join(path.dirname(fileURLToPath(fromFileUrl)), "static");
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Create Loop Operator Console HTTP server.
|
|
24
|
-
*
|
|
27
|
+
* Default bind is loopback; non-loopback hosts warn and stay allowed for LAN,
|
|
28
|
+
* matching Observe serve (mutations still require cookie + confirmation + Origin).
|
|
25
29
|
*/
|
|
26
30
|
export async function createConsoleServer(options) {
|
|
27
31
|
const host = options.host ?? "127.0.0.1";
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
const loopbackBind = isLoopbackHost(host);
|
|
33
|
+
if (!loopbackBind) {
|
|
34
|
+
process.stderr.write(`console serve: binding non-loopback host ${host} (Operator mutations enabled; no network auth — expose only on trusted networks)\n`);
|
|
30
35
|
}
|
|
31
36
|
const repoRoot = path.resolve(options.repoRoot);
|
|
32
37
|
const port = options.port ?? 8790;
|
|
38
|
+
const debug = options.debug === true;
|
|
39
|
+
const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? 65_000;
|
|
33
40
|
const staticDir = options.staticDir ?? defaultConsoleStaticDir();
|
|
41
|
+
const inspectStaticDir = options.inspectStaticDir ?? defaultObserveStaticDir();
|
|
42
|
+
const observeRouteContext = createObserveRouteContext(repoRoot, {
|
|
43
|
+
staticDir: inspectStaticDir,
|
|
44
|
+
});
|
|
34
45
|
const bootToken = issueBootCapabilityToken();
|
|
35
46
|
const operatorSessionId = `sess_${randomBytes(16).toString("hex")}`;
|
|
36
47
|
const appData = openConsoleAppData({
|
|
@@ -83,6 +94,7 @@ export async function createConsoleServer(options) {
|
|
|
83
94
|
refreshReadiness,
|
|
84
95
|
observeBaseUrl: options.observeBaseUrl,
|
|
85
96
|
fetchImpl: options.fetchImpl,
|
|
97
|
+
getConsoleOrigin: () => `http://${boundHost}:${boundPort}`,
|
|
86
98
|
};
|
|
87
99
|
let boundHost = host;
|
|
88
100
|
let boundPort = port;
|
|
@@ -92,6 +104,11 @@ export async function createConsoleServer(options) {
|
|
|
92
104
|
staticDir,
|
|
93
105
|
bootToken,
|
|
94
106
|
getConsoleOrigin,
|
|
107
|
+
allowNonLoopbackAccess: !loopbackBind,
|
|
108
|
+
inspect: {
|
|
109
|
+
ready: existsSync(path.join(inspectStaticDir, "index.html")),
|
|
110
|
+
routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
|
|
111
|
+
},
|
|
95
112
|
operator: {
|
|
96
113
|
actionContext,
|
|
97
114
|
operations,
|
|
@@ -101,6 +118,21 @@ export async function createConsoleServer(options) {
|
|
|
101
118
|
};
|
|
102
119
|
return new Promise((resolve, reject) => {
|
|
103
120
|
const server = createServer((req, res) => {
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
if (debug) {
|
|
123
|
+
let logged = false;
|
|
124
|
+
const onceLog = (note) => {
|
|
125
|
+
if (logged)
|
|
126
|
+
return;
|
|
127
|
+
logged = true;
|
|
128
|
+
logConsoleRequest(req, res, startedAt, note);
|
|
129
|
+
};
|
|
130
|
+
res.on("finish", () => onceLog());
|
|
131
|
+
res.on("close", () => {
|
|
132
|
+
if (!res.writableFinished)
|
|
133
|
+
onceLog("aborted");
|
|
134
|
+
});
|
|
135
|
+
}
|
|
104
136
|
void dispatch(req, res).catch((error) => {
|
|
105
137
|
if (!res.headersSent) {
|
|
106
138
|
const payload = JSON.stringify({
|
|
@@ -114,13 +146,54 @@ export async function createConsoleServer(options) {
|
|
|
114
146
|
}
|
|
115
147
|
});
|
|
116
148
|
});
|
|
149
|
+
server.keepAliveTimeout = keepAliveTimeoutMs;
|
|
150
|
+
server.headersTimeout = keepAliveTimeoutMs + 5_000;
|
|
151
|
+
server.requestTimeout = 0;
|
|
152
|
+
server.on("connection", (socket) => {
|
|
153
|
+
socket.on("error", () => {
|
|
154
|
+
// Ignore client resets while retaining one listener per connection.
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
server.on("clientError", (err, socket) => {
|
|
158
|
+
if (debug) {
|
|
159
|
+
process.stderr.write(`[console] ${new Date().toISOString()} clientError ${err.message}\n`);
|
|
160
|
+
}
|
|
161
|
+
if (!socket.destroyed) {
|
|
162
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
117
165
|
async function dispatch(req, res) {
|
|
118
166
|
// Refresh readiness pointer on each request surface
|
|
119
167
|
actionContext.readiness = getReadiness();
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
168
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
169
|
+
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
170
|
+
if (method === "GET" && pathname === "/api/health") {
|
|
171
|
+
await handleConsoleHealthRequest(req, res, routeContext);
|
|
172
|
+
return;
|
|
123
173
|
}
|
|
174
|
+
if (pathname.startsWith("/api/operator/v1/") ||
|
|
175
|
+
pathname.startsWith("/api/session/")) {
|
|
176
|
+
await handleConsoleOperatorRequest(req, res, routeContext);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (method === "GET" && pathname.startsWith("/api/")) {
|
|
180
|
+
const match = matchObserveGetRoute(pathname);
|
|
181
|
+
if (match) {
|
|
182
|
+
await handleMatchedObserveRoute(req, res, match, observeRouteContext);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (pathname.startsWith("/inspect")) {
|
|
187
|
+
await serveObserveStatic(req, res, inspectStaticDir, {
|
|
188
|
+
urlPrefix: "/inspect",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (pathname.startsWith("/api/")) {
|
|
193
|
+
sendJson(res, 404, { error: "Not found" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
await serveConsoleStatic(req, res, routeContext);
|
|
124
197
|
}
|
|
125
198
|
server.on("error", reject);
|
|
126
199
|
server.listen(port, host, () => {
|
|
@@ -132,7 +205,12 @@ export async function createConsoleServer(options) {
|
|
|
132
205
|
boundHost = host;
|
|
133
206
|
boundPort = addr.port;
|
|
134
207
|
const url = `http://${host}:${addr.port}`;
|
|
135
|
-
|
|
208
|
+
if (debug) {
|
|
209
|
+
process.stderr.write(`[console] debug logging enabled; keepAliveTimeout=${keepAliveTimeoutMs}ms\n`);
|
|
210
|
+
}
|
|
211
|
+
process.stderr.write(`[console] Loop Operator Console listening on ${url}${loopbackBind
|
|
212
|
+
? " (loopback)"
|
|
213
|
+
: " (non-loopback; trusted networks only)"}\n`);
|
|
136
214
|
resolve({
|
|
137
215
|
url,
|
|
138
216
|
host,
|
|
@@ -149,6 +227,26 @@ export async function createConsoleServer(options) {
|
|
|
149
227
|
});
|
|
150
228
|
});
|
|
151
229
|
}
|
|
230
|
+
function requestSource(req) {
|
|
231
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
232
|
+
if (typeof forwarded === "string" && forwarded.trim()) {
|
|
233
|
+
return forwarded.split(",")[0]?.trim() || forwarded.trim();
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(forwarded) && forwarded[0]) {
|
|
236
|
+
return forwarded[0].split(",")[0]?.trim() || forwarded[0];
|
|
237
|
+
}
|
|
238
|
+
const realIp = req.headers["x-real-ip"];
|
|
239
|
+
if (typeof realIp === "string" && realIp.trim()) {
|
|
240
|
+
return realIp.trim();
|
|
241
|
+
}
|
|
242
|
+
return req.socket.remoteAddress ?? "-";
|
|
243
|
+
}
|
|
244
|
+
function logConsoleRequest(req, res, startedAt, note) {
|
|
245
|
+
const ms = Date.now() - startedAt;
|
|
246
|
+
const ua = String(req.headers["user-agent"] ?? "-").slice(0, 120);
|
|
247
|
+
const suffix = note ? ` ${note}` : "";
|
|
248
|
+
process.stderr.write(`[console] ${new Date().toISOString()} ${req.method ?? "?"} ${req.url ?? "/"} ${res.statusCode} ${ms}ms from=${requestSource(req)} ua=${JSON.stringify(ua)}${suffix}\n`);
|
|
249
|
+
}
|
|
152
250
|
/** Test helper: replace readiness report in-process. */
|
|
153
251
|
export function setConsoleReadinessForTests(server, report) {
|
|
154
252
|
if (!server.getReadiness)
|