@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.
Files changed (33) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +4 -6
  4. package/dist/commands/init.js +3 -3
  5. package/dist/governance/exec-plans.js +4 -0
  6. package/dist/worker/cli.js +13 -12
  7. package/dist/worker/console/doctor.js +55 -2
  8. package/dist/worker/console/index.js +1 -0
  9. package/dist/worker/console/loopback.js +2 -2
  10. package/dist/worker/console/observe-link.js +7 -2
  11. package/dist/worker/console/operator-actions.js +30 -2
  12. package/dist/worker/console/operator-selection.js +92 -0
  13. package/dist/worker/console/operator-surface-health.js +23 -0
  14. package/dist/worker/console/recovery-cta.js +2 -2
  15. package/dist/worker/console/routes.js +45 -19
  16. package/dist/worker/console/security.js +29 -4
  17. package/dist/worker/console/server.js +106 -8
  18. package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
  19. package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
  20. package/dist/worker/console/static/index.html +2 -2
  21. package/dist/worker/observe/routes.js +63 -21
  22. package/dist/worker/observe/server.js +3 -10
  23. package/dist/worker/observe/static/index.html +6 -3
  24. package/dist/worker/observe/static/styles.css +53 -0
  25. package/docs/README.md +3 -2
  26. package/docs/architecture/evolution.md +6 -6
  27. package/docs/architecture/worker-and-feature.md +9 -9
  28. package/package.json +1 -1
  29. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  30. package/skills/loop-agent/references/command-reference.md +4 -2
  31. package/skills/loop-agent/references/harness-policy.md +1 -1
  32. package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
  33. 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 loopback +
55
- * Origin matches Console + Sec-Fetch-Site not cross-site.
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 (!isLoopbackHost(hostOnly)) {
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 (typeof origin !== "string" ||
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 { handleConsoleRequest, serveConsoleStatic, } from "./routes.js";
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
- * Non-loopback host throws before listen (fail closed).
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
- 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)`);
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 handled = await handleConsoleRequest(req, res, routeContext);
121
- if (!handled) {
122
- await serveConsoleStatic(req, res, routeContext);
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
- process.stderr.write(`[console] Loop Operator Console listening on ${url} (loopback only)\n`);
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)