@tea-agent/loop-agent 0.18.1 → 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 +21 -0
- package/README.md +4 -6
- 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/docs/README.md +3 -2
- package/docs/architecture/evolution.md +6 -6
- package/docs/architecture/worker-and-feature.md +9 -9
- 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
|
@@ -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)
|