talon-agent 5.24.0 → 5.25.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 (79) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +30 -0
  3. package/src/backend/agy/process/orphans.ts +5 -1
  4. package/src/backend/claude-sdk/one-shot.ts +8 -1
  5. package/src/backend/codex/factory.ts +5 -1
  6. package/src/backend/codex/plan-usage.ts +12 -0
  7. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  8. package/src/bootstrap.ts +1 -0
  9. package/src/cli.ts +18 -12
  10. package/src/core/agent-runtime/capabilities.ts +7 -0
  11. package/src/core/agents/index.ts +1 -1
  12. package/src/core/agents/prompt.ts +29 -2
  13. package/src/core/agents/runner.ts +7 -1
  14. package/src/core/agents/types.ts +6 -0
  15. package/src/core/background/cron/job-oneshot.ts +7 -1
  16. package/src/core/background/cron/scheduler.ts +28 -14
  17. package/src/core/background/cron/spec.ts +23 -3
  18. package/src/core/background/heartbeat/agent.ts +6 -0
  19. package/src/core/background/triggers/resume.ts +19 -4
  20. package/src/core/background/triggers/spawn.ts +1 -1
  21. package/src/core/config/index.ts +82 -0
  22. package/src/core/daemon/discovery.ts +160 -0
  23. package/src/core/daemon/pidfile.ts +107 -0
  24. package/src/core/daemon/respawn.ts +4 -1
  25. package/src/core/engine/backend-router/breaker.ts +158 -0
  26. package/src/core/engine/backend-router/headroom.ts +90 -22
  27. package/src/core/engine/backend-router/index.ts +11 -0
  28. package/src/core/engine/gateway-actions/agents/control.ts +9 -0
  29. package/src/core/engine/gateway-actions/agents/index.ts +4 -0
  30. package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
  31. package/src/core/engine/gateway-actions/cron.ts +5 -0
  32. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  33. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  34. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  35. package/src/core/engine/gateway-actions/models.ts +19 -4
  36. package/src/core/engine/gateway-routes.ts +31 -2
  37. package/src/core/engine/gateway.ts +11 -1
  38. package/src/core/fetch/classify.ts +73 -0
  39. package/src/core/fetch/curl-impersonate.ts +447 -0
  40. package/src/core/fetch/errors.ts +14 -0
  41. package/src/core/fetch/index.ts +133 -0
  42. package/src/core/fetch/ladder.ts +401 -0
  43. package/src/core/fetch/rungs.ts +319 -0
  44. package/src/core/fetch/types.ts +105 -0
  45. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  46. package/src/core/mcp-hub/child-guard.ts +215 -0
  47. package/src/core/mcp-hub/child-transport.ts +89 -39
  48. package/src/core/mcp-hub/children.ts +65 -9
  49. package/src/core/mcp-hub/guest-scope.ts +3 -2
  50. package/src/core/mcp-hub/index.ts +36 -16
  51. package/src/core/mcp-hub/launcher.ts +81 -37
  52. package/src/core/mcp-hub/proxy-server.ts +12 -8
  53. package/src/core/mcp-hub/reaper.ts +120 -0
  54. package/src/core/mesh/credentials/index.ts +1 -1
  55. package/src/core/mesh/credentials/store.ts +1 -1
  56. package/src/core/mesh/devices/service.ts +8 -0
  57. package/src/core/mesh/links/bridge-links.ts +37 -0
  58. package/src/core/mesh/links/companion-pairing.ts +2 -2
  59. package/src/core/plugin/mcp.ts +8 -8
  60. package/src/core/tools/bridge.ts +5 -0
  61. package/src/core/tools/content/web.ts +1 -1
  62. package/src/core/tools/index.ts +2 -1
  63. package/src/core/tools/ops/agents.ts +28 -0
  64. package/src/core/tools/ops/mesh.ts +21 -0
  65. package/src/core/tools/ops/scheduling.ts +15 -0
  66. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  67. package/src/frontend/telegram/actions/media.ts +164 -12
  68. package/src/index.ts +22 -19
  69. package/src/plugins/playwright/version-coupling.ts +172 -62
  70. package/src/storage/cron.ts +34 -2
  71. package/src/storage/db.ts +1 -0
  72. package/src/storage/repositories/cron-repo.ts +9 -0
  73. package/src/storage/sql/cron.sql +5 -5
  74. package/src/storage/sql/db.sql +5 -0
  75. package/src/storage/sql/schema.sql +2 -1
  76. package/src/storage/sql/statements.generated.ts +10 -6
  77. package/src/util/log.ts +2 -1
  78. package/src/util/paths.ts +2 -0
  79. package/src/core/background/triggers/pid.ts +0 -28
@@ -11,11 +11,21 @@
11
11
  * the operator's soft budget (`config.backendBudgets`). The
12
12
  * fallback for backends with no account API.
13
13
  *
14
- * A backend with neither reports `source: "none"` and headroom 1. That is a
15
- * deliberate "no evidence of pressure", not a claim of capacity — the
16
- * router's comparator ranks it *below* any backend with real telemetry at
17
- * the same headroom, so an unmeasured backend never outranks a measured one
18
- * it is tied with.
14
+ * A backend with neither reports `source: "none"` and headroom 0: nothing
15
+ * is known about it, so it is never *preferred*. It still clears the
16
+ * ceiling (there is no window to be over), so it runs work when nothing
17
+ * measured is available. It used to read as 1. That made a Codex install
18
+ * with an expired login, and so no usage signal, the router's favourite
19
+ * for 36 hours while every run on it failed.
20
+ *
21
+ * On top of either source sit two "this backend is not working" signals.
22
+ * Either one zeroes the headroom and pins the limiting window at 100%, so
23
+ * the ceiling drops the backend whenever anything else is left:
24
+ *
25
+ * - the backend's own telemetry reports a rejected credential
26
+ * (`UsageTelemetry.getAuthFailure`, e.g. the Codex usage endpoint's 401);
27
+ * - the run breaker is open (`breaker.ts`): an auth failure or repeated
28
+ * failures on runs routed there.
19
29
  *
20
30
  * Reads are cached for 60s per backend: `/usage`, the router and the
21
31
  * `plan_usage` tool all ask, and a plan lookup can be a subprocess spawn. A
@@ -31,6 +41,7 @@ import {
31
41
  listAvailableBackends,
32
42
  } from "../backend-controller/index.js";
33
43
  import { ledgerUsage } from "./ledger.js";
44
+ import { openBreaker } from "./breaker.js";
34
45
 
35
46
  /** How long a headroom reading is reused before the source is asked again. */
36
47
  export const HEADROOM_CACHE_MS = 60_000;
@@ -63,6 +74,11 @@ export interface BackendHeadroom {
63
74
  * same (cached) fetch the router ranked on, instead of asking twice.
64
75
  */
65
76
  readonly plan?: PlanUsage;
77
+ /**
78
+ * Why the backend is treated as unusable right now (a rejected login, an
79
+ * open breaker). Set means headroom 0 and a 100% limiting window.
80
+ */
81
+ readonly unavailable?: string;
66
82
  }
67
83
 
68
84
  interface CacheEntry {
@@ -183,13 +199,29 @@ export function headroomFromLedger(
183
199
  };
184
200
  }
185
201
 
186
- /** The "nothing to measure" reading. Headroom 1, but lowest ranking source. */
202
+ /** The "nothing to measure" reading: headroom 0, but under any ceiling. */
187
203
  function unknownHeadroom(
188
204
  id: string,
189
205
  label: string,
190
206
  now: number,
191
207
  ): BackendHeadroom {
192
- return { id, label, headroom: 1, source: "none", fetchedAt: now };
208
+ return { id, label, headroom: 0, source: "none", fetchedAt: now };
209
+ }
210
+
211
+ /** What a backend's telemetry said: its plan windows and any auth failure. */
212
+ interface PlanRead {
213
+ usage?: PlanUsage;
214
+ authFailure?: string;
215
+ }
216
+
217
+ function authFailureOf(
218
+ usage: { getAuthFailure?(): string | undefined } | undefined,
219
+ ): string | undefined {
220
+ try {
221
+ return usage?.getAuthFailure?.call(usage) || undefined;
222
+ } catch {
223
+ return undefined;
224
+ }
193
225
  }
194
226
 
195
227
  /**
@@ -202,22 +234,35 @@ function unknownHeadroom(
202
234
  * is *not* currently in use. The read is the backend's own cached one, so at
203
235
  * most one boot per cache window.
204
236
  */
205
- async function readPlanUsage(id: string): Promise<PlanUsage | undefined> {
237
+ async function readPlanUsage(id: string): Promise<PlanRead> {
206
238
  const pooled = getPooledBackend(id);
207
- if (pooled?.usage?.getPlanUsage) {
208
- return pooled.usage.getPlanUsage.call(pooled.usage);
239
+ if (pooled) {
240
+ const usage = pooled.usage?.getPlanUsage
241
+ ? await pooled.usage.getPlanUsage.call(pooled.usage)
242
+ : undefined; // pooled, but reports no plan windows
243
+ // Read after the plan fetch: that fetch is what discovers a 401.
244
+ const authFailure = authFailureOf(pooled.usage);
245
+ return {
246
+ ...(usage ? { usage } : {}),
247
+ ...(authFailure ? { authFailure } : {}),
248
+ };
209
249
  }
210
- if (pooled) return undefined; // pooled, but reports no plan windows
211
250
  let acquired;
212
251
  try {
213
252
  acquired = await acquireBackendInstance(id);
214
253
  } catch {
215
- return undefined; // can't boot it (not configured, no auth) — stay quiet
254
+ return {}; // can't boot it (not configured, no auth) — stay quiet
216
255
  }
217
256
  try {
218
- const read = acquired.backend.usage?.getPlanUsage;
219
- if (!read || !acquired.backend.usage) return undefined;
220
- return await read.call(acquired.backend.usage);
257
+ const telemetry = acquired.backend.usage;
258
+ const usage = telemetry?.getPlanUsage
259
+ ? await telemetry.getPlanUsage.call(telemetry)
260
+ : undefined;
261
+ const authFailure = authFailureOf(telemetry);
262
+ return {
263
+ ...(usage ? { usage } : {}),
264
+ ...(authFailure ? { authFailure } : {}),
265
+ };
221
266
  } finally {
222
267
  await acquired.release().catch(() => {});
223
268
  }
@@ -238,16 +283,18 @@ export async function getBackendHeadroom(
238
283
  const now = options?.now ?? Date.now();
239
284
  const cached = cache.get(id);
240
285
  if (!options?.force && cached && now - cached.cachedAt < HEADROOM_CACHE_MS) {
241
- return cached.value;
286
+ return withBreaker(cached.value, now);
242
287
  }
243
288
 
244
289
  let value: BackendHeadroom;
245
290
  try {
246
- const plan = headroomFromPlan(id, label, await readPlanUsage(id));
291
+ const read = await readPlanUsage(id);
292
+ const plan = headroomFromPlan(id, label, read.usage);
247
293
  value =
248
294
  plan ??
249
295
  headroomFromLedger(id, label, config, now) ??
250
296
  unknownHeadroom(id, label, now);
297
+ if (read.authFailure) value = unavailable(value, read.authFailure);
251
298
  } catch {
252
299
  // The source is unreachable this minute. Keeping the last good reading
253
300
  // is the conservative answer: forgetting it would read as "empty" and
@@ -257,7 +304,29 @@ export async function getBackendHeadroom(
257
304
  : unknownHeadroom(id, label, now);
258
305
  }
259
306
  cache.set(id, { value, cachedAt: now });
260
- return value;
307
+ return withBreaker(value, now);
308
+ }
309
+
310
+ /** Mark a reading unusable: zero headroom, limiting window pinned at 100%. */
311
+ function unavailable(value: BackendHeadroom, why: string): BackendHeadroom {
312
+ return {
313
+ ...value,
314
+ headroom: 0,
315
+ limiting: { label: why, percent: 100 },
316
+ unavailable: why,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Overlay the run breaker. Applied on every read rather than cached: the
322
+ * breaker opens and closes on run outcomes, not on the headroom clock.
323
+ */
324
+ function withBreaker(value: BackendHeadroom, now: number): BackendHeadroom {
325
+ if (value.unavailable) return value;
326
+ const breaker = openBreaker(value.id, now);
327
+ if (!breaker) return value;
328
+ const mins = Math.max(1, Math.ceil((breaker.until - now) / 60_000));
329
+ return unavailable(value, `breaker open ${mins}m — ${breaker.reason}`);
261
330
  }
262
331
 
263
332
  /** Headroom for every backend the config exposes, in config order. */
@@ -275,11 +344,10 @@ export async function collectBackendHeadroom(
275
344
 
276
345
  /** One-line rendering shared by `/usage`, `plan_usage` and the router log. */
277
346
  export function formatHeadroom(entry: BackendHeadroom): string {
347
+ if (entry.unavailable) return `0% — unavailable: ${entry.unavailable}`;
348
+ if (entry.source === "none") return "unmeasured — no usage signal";
278
349
  const pct = `${Math.round(entry.headroom * 100)}%`;
279
- const detail =
280
- entry.source === "none"
281
- ? "no usage signal"
282
- : `${entry.limiting?.label ?? "window"} ${Math.round(entry.limiting?.percent ?? 0)}% used`;
350
+ const detail = `${entry.limiting?.label ?? "window"} ${Math.round(entry.limiting?.percent ?? 0)}% used`;
283
351
  const tag = entry.source === "ledger" ? " (local budget)" : "";
284
352
  const stale = entry.stale ? " (stale)" : "";
285
353
  return `${pct} — ${detail}${tag}${stale}`;
@@ -20,6 +20,17 @@ export {
20
20
  LEDGER_RETENTION_MS,
21
21
  LEDGER_SHORT_WINDOW_MS,
22
22
  } from "./ledger.js";
23
+ export {
24
+ isAuthFailureMessage,
25
+ openBreaker,
26
+ recordBackendRunFailure,
27
+ recordBackendRunSuccess,
28
+ resetBackendBreakersForTest,
29
+ BREAKER_BASE_COOLOFF_MS,
30
+ BREAKER_FAILURE_THRESHOLD,
31
+ BREAKER_MAX_COOLOFF_MS,
32
+ type OpenBreaker,
33
+ } from "./breaker.js";
23
34
  export {
24
35
  collectBackendHeadroom,
25
36
  formatHeadroom,
@@ -20,6 +20,7 @@ import {
20
20
  getAgentCaps,
21
21
  killAgent,
22
22
  spawnAgent,
23
+ wantsPreflight,
23
24
  type AgentParent,
24
25
  type AgentRecord,
25
26
  } from "../../../agents/index.js";
@@ -150,6 +151,7 @@ function readSpawnBody(body: Record<string, unknown>):
150
151
  model?: string;
151
152
  reasoningEffort?: ReasoningEffortLevel;
152
153
  timeoutMs?: number;
154
+ preflight: boolean;
153
155
  }
154
156
  | { ok: false; error: string } {
155
157
  const brief = String(body.brief ?? "").trim();
@@ -168,10 +170,15 @@ function readSpawnBody(body: Record<string, unknown>):
168
170
  if (timeoutS !== undefined && !Number.isFinite(timeoutS)) {
169
171
  return { ok: false, error: "timeout_s must be a number of seconds" };
170
172
  }
173
+ const preflight = body.preflight;
174
+ if (preflight !== undefined && typeof preflight !== "boolean") {
175
+ return { ok: false, error: "preflight must be true or false" };
176
+ }
171
177
  return {
172
178
  ok: true,
173
179
  brief,
174
180
  label,
181
+ preflight: wantsPreflight(brief, preflight),
175
182
  ...(body.backend ? { backendId: String(body.backend) } : {}),
176
183
  ...(body.model ? { model: String(body.model) } : {}),
177
184
  ...(effort !== undefined
@@ -200,6 +207,7 @@ export const agentControlHandlers: SharedActionHandlers = {
200
207
  ...(parsed.timeoutMs !== undefined
201
208
  ? { timeoutMs: parsed.timeoutMs }
202
209
  : {}),
210
+ preflight: parsed.preflight,
203
211
  });
204
212
  if (!outcome.ok) return { ok: false, error: outcome.error };
205
213
  const timeoutS = Math.round(clampTimeout(parsed.timeoutMs) / 1000);
@@ -211,6 +219,7 @@ export const agentControlHandlers: SharedActionHandlers = {
211
219
  `Backend: ${outcome.backendId}/${outcome.model}` +
212
220
  `${outcome.routing ? ` (routed: ${outcome.routing})` : ""}\n` +
213
221
  `Timeout: ${timeoutS}s\n` +
222
+ (parsed.preflight ? `Pre-flight lane: on\n` : "") +
214
223
  `It runs in the background. You will be woken with its report — ` +
215
224
  `carry on with what you were doing.`,
216
225
  };
@@ -6,6 +6,8 @@
6
6
  * - `report` — what a sub-agent calls about itself and its siblings:
7
7
  * report_result / message_parent / check_inbox / list_peers /
8
8
  * message_peer.
9
+ * - `preflight` — run_preflight: the pre-flight lane (light CI suite) in
10
+ * the caller's checkout, so an agent pushes only on green.
9
11
  *
10
12
  * Both sets are reachable from an `agent:<id>` context: the gateway routes
11
13
  * those chat keys straight here (see `Gateway.handleAction`), because a
@@ -15,11 +17,13 @@
15
17
 
16
18
  import type { SharedActionHandlers } from "../types.js";
17
19
  import { agentControlHandlers } from "./control.js";
20
+ import { agentPreflightHandlers } from "./preflight.js";
18
21
  import { agentReportHandlers } from "./report.js";
19
22
 
20
23
  export const agentHandlers: SharedActionHandlers = {
21
24
  ...agentControlHandlers,
22
25
  ...agentReportHandlers,
26
+ ...agentPreflightHandlers,
23
27
  };
24
28
 
25
29
  /**
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `run_preflight` — run a checkout's pre-flight lane and return its verdict.
3
+ *
4
+ * The pre-flight lane (`scripts/preflight.sh`, `npm run preflight`) is the
5
+ * light CI suite an agent runs before `git push`, so GitHub confirms a change
6
+ * instead of being the first compiler it meets. This action runs it in the
7
+ * caller's checkout on the daemon host and answers with the one-line verdict,
8
+ * the per-step table and the tail of every failing step's log — enough to
9
+ * fix the change without re-running anything by hand.
10
+ *
11
+ * Available from a chat and from inside a sub-agent (it is in
12
+ * `agentContextActions`), because agents are the ones opening PRs.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { readFile, stat } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { dirs } from "../../../../util/paths.js";
19
+ import { resolvePathParam } from "../native/params.js";
20
+ import type { ActionResult } from "../../../types.js";
21
+ import type { SharedActionHandlers } from "../types.js";
22
+
23
+ /** Relative path of the lane inside a checkout. */
24
+ const SCRIPT = join("scripts", "preflight.sh");
25
+ /** Hard cap on one run. The lane targets ≤5 min; this is the backstop. */
26
+ const PREFLIGHT_TIMEOUT_MS = 600_000;
27
+ /** Grace between SIGTERM and SIGKILL of the run's process group. */
28
+ const KILL_GRACE_MS = 5_000;
29
+ /** Lines of each failing step's log to hand back. */
30
+ const FAIL_TAIL_LINES = 30;
31
+
32
+ interface PreflightStep {
33
+ readonly name: string;
34
+ readonly status: "pass" | "fail" | "skipped";
35
+ readonly ms: number;
36
+ readonly note?: string;
37
+ }
38
+
39
+ interface PreflightSummary {
40
+ readonly verdict: "green" | "red";
41
+ readonly ok: boolean;
42
+ readonly base: string;
43
+ readonly totalMs: number;
44
+ readonly failed: readonly string[];
45
+ readonly steps: readonly PreflightStep[];
46
+ }
47
+
48
+ interface RunOutcome {
49
+ readonly code: number | null;
50
+ readonly timedOut: boolean;
51
+ readonly output: string;
52
+ }
53
+
54
+ /** The checkout root for `dir`: its git toplevel, else `dir` itself. */
55
+ function repoRoot(dir: string): Promise<string> {
56
+ return new Promise((resolveRoot) => {
57
+ const child = spawn("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
58
+ let out = "";
59
+ child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString()));
60
+ child.on("error", () => resolveRoot(dir));
61
+ child.on("close", (code) =>
62
+ resolveRoot(code === 0 && out.trim() ? out.trim() : dir),
63
+ );
64
+ });
65
+ }
66
+
67
+ async function isFile(path: string): Promise<boolean> {
68
+ try {
69
+ return (await stat(path)).isFile();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Run the lane in its own process group so a timeout takes every child. */
76
+ function runLane(root: string, timeoutMs: number): Promise<RunOutcome> {
77
+ return new Promise((resolveRun) => {
78
+ const child = spawn("bash", [SCRIPT], {
79
+ cwd: root,
80
+ detached: process.platform !== "win32",
81
+ env: { ...process.env, PREFLIGHT_QUIET: "1", CI: "1" },
82
+ });
83
+ let output = "";
84
+ const keep = (chunk: Buffer): void => {
85
+ output = (output + chunk.toString()).slice(-8_000);
86
+ };
87
+ child.stdout.on("data", keep);
88
+ child.stderr.on("data", keep);
89
+ let timedOut = false;
90
+ const killGroup = (signal: NodeJS.Signals): void => {
91
+ try {
92
+ if (child.pid && process.platform !== "win32") {
93
+ process.kill(-child.pid, signal);
94
+ } else child.kill(signal);
95
+ } catch {
96
+ // Already gone.
97
+ }
98
+ };
99
+ const timer = setTimeout(() => {
100
+ timedOut = true;
101
+ killGroup("SIGTERM");
102
+ setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref();
103
+ }, timeoutMs);
104
+ child.on("error", (err) => {
105
+ clearTimeout(timer);
106
+ resolveRun({ code: null, timedOut, output: String(err) });
107
+ });
108
+ child.on("close", (code) => {
109
+ clearTimeout(timer);
110
+ resolveRun({ code, timedOut, output });
111
+ });
112
+ });
113
+ }
114
+
115
+ async function readSummary(root: string): Promise<PreflightSummary | null> {
116
+ try {
117
+ const raw = await readFile(join(root, ".preflight", "last.json"), "utf8");
118
+ return JSON.parse(raw) as PreflightSummary;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function logTail(root: string, step: string): Promise<string> {
125
+ try {
126
+ const raw = await readFile(join(root, ".preflight", `${step}.log`), "utf8");
127
+ return raw.trimEnd().split("\n").slice(-FAIL_TAIL_LINES).join("\n");
128
+ } catch {
129
+ return "(no log)";
130
+ }
131
+ }
132
+
133
+ function stepLine(step: PreflightStep): string {
134
+ const mark =
135
+ step.status === "pass" ? "✓" : step.status === "fail" ? "✗" : "·";
136
+ const time =
137
+ step.status === "skipped" ? "" : ` (${Math.round(step.ms / 1000)}s)`;
138
+ const note = step.status === "skipped" && step.note ? ` — ${step.note}` : "";
139
+ return `${mark} ${step.name}${time}${note}`;
140
+ }
141
+
142
+ /** Render the summary a model can act on: verdict, table, failing tails. */
143
+ async function renderPreflight(
144
+ root: string,
145
+ summary: PreflightSummary,
146
+ ): Promise<string> {
147
+ const verdict = summary.ok
148
+ ? `Pre-flight GREEN in ${Math.round(summary.totalMs / 1000)}s — safe to push.`
149
+ : `Pre-flight RED in ${Math.round(summary.totalMs / 1000)}s — failed: ` +
150
+ `${summary.failed.join(", ")}. Fix before pushing, or explain in the PR body.`;
151
+ const parts = [
152
+ verdict,
153
+ `Base: ${summary.base}. Summary: ${join(root, ".preflight", "last.json")}`,
154
+ "",
155
+ ...summary.steps.map(stepLine),
156
+ ];
157
+ for (const name of summary.failed) {
158
+ parts.push("", `── ${name} (last ${FAIL_TAIL_LINES} lines) ──`);
159
+ parts.push(await logTail(root, name));
160
+ }
161
+ return parts.join("\n");
162
+ }
163
+
164
+ /** Run the lane in `dir`'s checkout and describe the result. */
165
+ async function runPreflight(
166
+ dir: string,
167
+ timeoutMs: number = PREFLIGHT_TIMEOUT_MS,
168
+ ): Promise<ActionResult> {
169
+ try {
170
+ if (!(await stat(dir)).isDirectory()) {
171
+ return { ok: false, error: `cwd is not a directory: ${dir}` };
172
+ }
173
+ } catch {
174
+ return { ok: false, error: `Working directory does not exist: ${dir}` };
175
+ }
176
+ const root = await repoRoot(dir);
177
+ if (!(await isFile(join(root, SCRIPT)))) {
178
+ return {
179
+ ok: false,
180
+ error:
181
+ `No ${SCRIPT} in ${root}. Pass cwd = the root of a talon checkout ` +
182
+ `(a branch that has the pre-flight lane).`,
183
+ };
184
+ }
185
+ const run = await runLane(root, timeoutMs);
186
+ if (run.timedOut) {
187
+ return {
188
+ ok: false,
189
+ error:
190
+ `Pre-flight did not finish within ${Math.round(timeoutMs / 1000)}s ` +
191
+ `and was killed. Tail of its output:\n${run.output.slice(-2_000)}`,
192
+ };
193
+ }
194
+ const summary = await readSummary(root);
195
+ if (!summary) {
196
+ return {
197
+ ok: false,
198
+ error:
199
+ `Pre-flight exited ${run.code} without writing .preflight/last.json. ` +
200
+ `Output:\n${run.output.slice(-2_000)}`,
201
+ };
202
+ }
203
+ // A red lane is a successful tool call with a red verdict — the model is
204
+ // meant to read it and act, not treat it as the tool breaking.
205
+ return { ok: true, text: await renderPreflight(root, summary) };
206
+ }
207
+
208
+ export const agentPreflightHandlers: SharedActionHandlers = {
209
+ run_preflight: (body) => {
210
+ const raw =
211
+ typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : "";
212
+ const dir = raw ? resolvePathParam(raw, undefined) : dirs.workspace;
213
+ return runPreflight(dir);
214
+ },
215
+ };
@@ -133,6 +133,9 @@ export const cronHandlers: SharedActionHandlers = {
133
133
  : null,
134
134
  endAt !== undefined ? `ends: ${new Date(endAt).toISOString()}` : null,
135
135
  spec.catchup ? `catch-up: ${spec.catchup}` : null,
136
+ spec.timeoutMs !== undefined
137
+ ? `timeout: ${Math.round(spec.timeoutMs / 1000)}s`
138
+ : null,
136
139
  ]
137
140
  .filter(Boolean)
138
141
  .join(", ");
@@ -173,6 +176,8 @@ export const cronHandlers: SharedActionHandlers = {
173
176
  if (j.catchup && j.catchup !== "skip")
174
177
  bounds.push(`catch-up: ${j.catchup}`);
175
178
  if (j.model) bounds.push(`model: ${j.model}`);
179
+ if (j.timeoutMs !== undefined)
180
+ bounds.push(`timeout: ${Math.round(j.timeoutMs / 1000)}s`);
176
181
  return [
177
182
  `- ${j.name} (${status})`,
178
183
  ` ID: ${j.id}`,
@@ -7,15 +7,16 @@
7
7
  * credentials), the loopback gateway, a router admin page on the LAN.
8
8
  * So before every request — the first one and each redirect hop — the
9
9
  * host is resolved and EVERY address it resolves to must be public.
10
- * Redirects are followed by hand (never by fetch) so a public page
11
- * cannot bounce the request into a private one.
10
+ * Redirects are followed by hand (never by the transport) in the fetch
11
+ * ladder (core/fetch/ladder.ts), which calls `assertPublicUrl` on every
12
+ * hop, so a public page cannot bounce the request into a private one.
12
13
  *
13
- * Residual risk, stated plainly: fetch resolves the name again after we
14
- * checked it, so a DNS-rebinding server with a zero TTL can still race
15
- * us. Pinning the checked address would need a custom connector, which
16
- * Bun (one of the two runtimes) does not honour. The guard closes the
17
- * direct, redirect and static-DNS paths; rebinding needs a hostile DNS
18
- * server and a lucky race.
14
+ * Residual risk, stated plainly: the runtime's fetch (the "plain" rung)
15
+ * resolves the name again after we checked it, so a DNS-rebinding server
16
+ * with a zero TTL can still race it. Pinning the checked address would
17
+ * need a custom connector, which Bun does not honour. The curl rungs do
18
+ * pin (`--resolve` to the checked addresses); requests through a SOCKS
19
+ * exit are resolved by the exit, outside this host's network.
19
20
  *
20
21
  * The guard is opt-in: `fetch_url` applies it only when the operator sets
21
22
  * `fetchUrl.allowPrivateNetworks: false`. By default the agent can read
@@ -33,9 +34,6 @@ const defaultResolver: Resolver = async (host) =>
33
34
 
34
35
  export class BlockedUrlError extends Error {}
35
36
 
36
- const MAX_REDIRECTS = 5;
37
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
38
-
39
37
  // ── Address classification ──────────────────────────────────────────────────
40
38
 
41
39
  /** Non-public IPv4 ranges: [network, prefix length]. */
@@ -135,11 +133,13 @@ export function isBlockedAddress(ip: string): boolean {
135
133
  /**
136
134
  * Throw unless `url` is http(s) and its host resolves only to public
137
135
  * addresses. A literal IP is judged directly; a name is resolved.
136
+ * Returns the checked addresses so a caller that can pin the connection
137
+ * to them (curl `--resolve`) closes the rebinding race.
138
138
  */
139
139
  export async function assertPublicUrl(
140
140
  url: URL,
141
141
  resolve: Resolver = defaultResolver,
142
- ): Promise<void> {
142
+ ): Promise<string[]> {
143
143
  if (url.protocol !== "http:" && url.protocol !== "https:") {
144
144
  throw new BlockedUrlError("URL must use http or https protocol");
145
145
  }
@@ -166,36 +166,5 @@ export async function assertPublicUrl(
166
166
  `Remove fetchUrl.allowPrivateNetworks: false from config.json (or set it to true) to allow local addresses.`,
167
167
  );
168
168
  }
169
- }
170
-
171
- export type GuardedFetchOptions = {
172
- allowPrivateNetworks?: boolean;
173
- resolve?: Resolver;
174
- maxRedirects?: number;
175
- };
176
-
177
- /**
178
- * `fetch` with the guard applied to the first request and to every
179
- * redirect hop. Returns the final (non-redirect) response.
180
- */
181
- export async function guardedFetch(
182
- input: string,
183
- init: RequestInit,
184
- options: GuardedFetchOptions = {},
185
- ): Promise<Response> {
186
- const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS;
187
- let url = new URL(input);
188
- for (let hop = 0; ; hop++) {
189
- if (!options.allowPrivateNetworks) {
190
- await assertPublicUrl(url, options.resolve);
191
- }
192
- const resp = await fetch(url, { ...init, redirect: "manual" });
193
- const location = resp.headers.get("location");
194
- if (!REDIRECT_STATUSES.has(resp.status) || !location) return resp;
195
- if (hop >= maxRedirects) {
196
- throw new BlockedUrlError(`Too many redirects (max ${maxRedirects})`);
197
- }
198
- await resp.body?.cancel().catch(() => {});
199
- url = new URL(location, url);
200
- }
169
+ return addresses;
201
170
  }