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.
- package/package.json +2 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- 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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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:
|
|
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<
|
|
237
|
+
async function readPlanUsage(id: string): Promise<PlanRead> {
|
|
206
238
|
const pooled = getPooledBackend(id);
|
|
207
|
-
if (pooled
|
|
208
|
-
|
|
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
|
|
254
|
+
return {}; // can't boot it (not configured, no auth) — stay quiet
|
|
216
255
|
}
|
|
217
256
|
try {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
11
|
-
*
|
|
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
|
|
14
|
-
* checked it, so a DNS-rebinding server
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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<
|
|
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
|
}
|