@foldrun/core 0.4.0 → 0.5.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +6 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +61 -2
- package/dist/src/step-exec.js +414 -36
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The family of headers that carries the answer. */
|
|
2
|
+
export declare const UNIFIED_PREFIX = "anthropic-ratelimit-unified";
|
|
3
|
+
/** The only headers that are ever copied out of an upstream response.
|
|
4
|
+
* An allow-list rather than a prefix match, so a header we have not
|
|
5
|
+
* thought about cannot reach a trace by being named plausibly. */
|
|
6
|
+
export declare const REFUSAL_HEADERS: readonly string[];
|
|
7
|
+
/** The marker a refusal line carries through the egress log, the
|
|
8
|
+
* translator log and the run trace, so the runner can find the sentence
|
|
9
|
+
* again among lines it did not write. */
|
|
10
|
+
export declare const REFUSAL_MARK = "provider refusal:";
|
|
11
|
+
/** Anything with a `get(name)` — a `Headers`, or a plain object wrapped by
|
|
12
|
+
* `headersFrom` below. */
|
|
13
|
+
export interface HeaderBag {
|
|
14
|
+
get(name: string): string | null | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Read a plain record as a header bag, case-insensitively. */
|
|
17
|
+
export declare function headersFrom(record: Record<string, string | string[] | undefined>): HeaderBag;
|
|
18
|
+
/**
|
|
19
|
+
* The allow-listed headers, and nothing else, as a small record. This is
|
|
20
|
+
* the whole of what any part of foldrun keeps from an upstream response's
|
|
21
|
+
* headers — the proxy calls it at the boundary and passes the result on.
|
|
22
|
+
*/
|
|
23
|
+
export declare function captureRefusalHeaders(headers: HeaderBag): Record<string, string>;
|
|
24
|
+
export interface RefusalContext {
|
|
25
|
+
/** The upstream status. */
|
|
26
|
+
status: number;
|
|
27
|
+
/** What `captureRefusalHeaders` kept. Empty is meaningful: it is how a
|
|
28
|
+
* dead credential is told from a spent window. */
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/** The step's own zone, from the clock cascade. */
|
|
31
|
+
timezone?: string;
|
|
32
|
+
/** Where the step stands on a second supply. Said out loud on every
|
|
33
|
+
* refusal, because "no fallback" is becoming the normal state and must
|
|
34
|
+
* read as a decision rather than as something that went missing. Left
|
|
35
|
+
* out by a caller that cannot know — the egress proxy sees a response,
|
|
36
|
+
* not a flow — and added by the runner, which can. */
|
|
37
|
+
supply?: SupplyState;
|
|
38
|
+
/** How the endpoint is named in the trace: `api.anthropic.com`. */
|
|
39
|
+
provider?: string;
|
|
40
|
+
/** For tests, and for a reset that has already passed. */
|
|
41
|
+
now?: Date;
|
|
42
|
+
}
|
|
43
|
+
/** The short name a person uses for a zone: `Australia/Sydney` → Sydney,
|
|
44
|
+
* `UTC` → UTC, `+10:00` → UTC+10:00. */
|
|
45
|
+
export declare function zoneWord(timezone: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* A unix-second reset as a person in that zone reads it: `14:00 Sydney`,
|
|
48
|
+
* with the date when it is not today there. Null for a stamp that is not
|
|
49
|
+
* one, so a garbled header costs a clause rather than the sentence.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resetWhen(raw: string | undefined, timezone: string, now?: Date): string | null;
|
|
52
|
+
/** Where a step stands on a second supply: none configured, one about to
|
|
53
|
+
* be tried, or one that has been tried and refused as well. */
|
|
54
|
+
export type SupplyState = "none" | "trying" | "exhausted";
|
|
55
|
+
/** The clause that says whether there is anywhere else to go. Always on a
|
|
56
|
+
* refusal the runner finalises: the absence of a fallback is a state to
|
|
57
|
+
* state, not a silence to notice. */
|
|
58
|
+
export declare function supplyNote(state: SupplyState | undefined): string;
|
|
59
|
+
/**
|
|
60
|
+
* The sentence. One line, this project's voice, safe to put in a trace and
|
|
61
|
+
* on a failure line.
|
|
62
|
+
*
|
|
63
|
+
* Three shapes, in the order they are told apart:
|
|
64
|
+
*
|
|
65
|
+
* 1. rate-limit headers present → a supply problem. Which window, how
|
|
66
|
+
* used, whether overage could have covered it and why it did not, and
|
|
67
|
+
* when it clears in the step's own zone.
|
|
68
|
+
* 2. no rate-limit headers and a 401/403 → a credential problem. Said
|
|
69
|
+
* explicitly, including that this is NOT quota, because assuming the
|
|
70
|
+
* opposite is what cost an hour.
|
|
71
|
+
* 3. anything else → the status, plainly, plus whatever the provider
|
|
72
|
+
* asked us to wait.
|
|
73
|
+
*/
|
|
74
|
+
export declare function explainRefusal(ctx: RefusalContext): string;
|
|
75
|
+
/** The same sentence, marked, for a log line the runner reads back. */
|
|
76
|
+
export declare function refusalLine(ctx: RefusalContext): string;
|
|
77
|
+
/** Is this status one we explain? The set the runner already treats as a
|
|
78
|
+
* provider refusal, plus 402 which is what started all this. */
|
|
79
|
+
export declare function isRefusalStatus(status: number): boolean;
|
|
80
|
+
/** Pull the sentence back out of a trace line that carries the mark,
|
|
81
|
+
* wherever in the line it sits — the translator's lines are prefixed on
|
|
82
|
+
* their way into the record, and the egress proxy's are not. */
|
|
83
|
+
export declare function refusalFromLine(text: string): string | null;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Why the model supply said no.
|
|
2
|
+
//
|
|
3
|
+
// A step whose model call is refused used to leave two facts in the record:
|
|
4
|
+
// a status code, and a sentence written by the SDK. In the incident this
|
|
5
|
+
// file exists for, that was:
|
|
6
|
+
//
|
|
7
|
+
// egress: POST api.anthropic.com/v1/messages → 401 × 4 [FOLDRUN_MODEL_KEY]
|
|
8
|
+
// API Error: 402 This request would exceed your available credits
|
|
9
|
+
//
|
|
10
|
+
// Both are true and neither is the reason. The reason was that the
|
|
11
|
+
// subscription behind the key had its five-hour window part used and
|
|
12
|
+
// overage switched off for lack of credits — a state that clears by itself
|
|
13
|
+
// at a known minute — and the fallback provider had no balance. Working
|
|
14
|
+
// that out took an hour on the box, most of it spent assuming the key had
|
|
15
|
+
// been revoked. The key was fine.
|
|
16
|
+
//
|
|
17
|
+
// Anthropic says all of it in response headers on the very response that
|
|
18
|
+
// carried the refusal:
|
|
19
|
+
//
|
|
20
|
+
// anthropic-ratelimit-unified-status: allowed | rejected
|
|
21
|
+
// anthropic-ratelimit-unified-5h-status / -7d-status
|
|
22
|
+
// anthropic-ratelimit-unified-5h-utilization: 0.35
|
|
23
|
+
// anthropic-ratelimit-unified-overage-status: rejected
|
|
24
|
+
// anthropic-ratelimit-unified-overage-disabled-reason: out_of_credits
|
|
25
|
+
// anthropic-ratelimit-unified-reset / -5h-reset / -7d-reset (unix seconds)
|
|
26
|
+
// retry-after (on 429)
|
|
27
|
+
//
|
|
28
|
+
// We logged none of it. This file turns that header set into one sentence
|
|
29
|
+
// in the step's own trace and on the step's failure line, in the step's own
|
|
30
|
+
// timezone, and — the distinction that cost the hour — tells a spent window
|
|
31
|
+
// apart from a dead credential. A refusal carrying no rate-limit headers at
|
|
32
|
+
// all is a credential problem; a refusal carrying them is a supply problem
|
|
33
|
+
// and usually a clock.
|
|
34
|
+
//
|
|
35
|
+
// Two rules hold everywhere below. Never log a credential: nothing here
|
|
36
|
+
// reads a request header, and the only response headers it will look at are
|
|
37
|
+
// the allow-listed ones named above. Never log the whole header set: a
|
|
38
|
+
// provider is free to put anything in one, and a trace is read by people
|
|
39
|
+
// who are not thinking about that.
|
|
40
|
+
import { localDate } from "./clock.js";
|
|
41
|
+
/** The family of headers that carries the answer. */
|
|
42
|
+
export const UNIFIED_PREFIX = "anthropic-ratelimit-unified";
|
|
43
|
+
/** The only headers that are ever copied out of an upstream response.
|
|
44
|
+
* An allow-list rather than a prefix match, so a header we have not
|
|
45
|
+
* thought about cannot reach a trace by being named plausibly. */
|
|
46
|
+
export const REFUSAL_HEADERS = [
|
|
47
|
+
`${UNIFIED_PREFIX}-status`,
|
|
48
|
+
`${UNIFIED_PREFIX}-reset`,
|
|
49
|
+
`${UNIFIED_PREFIX}-5h-status`,
|
|
50
|
+
`${UNIFIED_PREFIX}-5h-utilization`,
|
|
51
|
+
`${UNIFIED_PREFIX}-5h-reset`,
|
|
52
|
+
`${UNIFIED_PREFIX}-7d-status`,
|
|
53
|
+
`${UNIFIED_PREFIX}-7d-utilization`,
|
|
54
|
+
`${UNIFIED_PREFIX}-7d-reset`,
|
|
55
|
+
`${UNIFIED_PREFIX}-overage-status`,
|
|
56
|
+
`${UNIFIED_PREFIX}-overage-disabled-reason`,
|
|
57
|
+
"retry-after",
|
|
58
|
+
];
|
|
59
|
+
/** The marker a refusal line carries through the egress log, the
|
|
60
|
+
* translator log and the run trace, so the runner can find the sentence
|
|
61
|
+
* again among lines it did not write. */
|
|
62
|
+
export const REFUSAL_MARK = "provider refusal:";
|
|
63
|
+
/** Read a plain record as a header bag, case-insensitively. */
|
|
64
|
+
export function headersFrom(record) {
|
|
65
|
+
const lower = new Map();
|
|
66
|
+
for (const [k, v] of Object.entries(record)) {
|
|
67
|
+
if (v === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
lower.set(k.toLowerCase(), Array.isArray(v) ? v.join(", ") : v);
|
|
70
|
+
}
|
|
71
|
+
return { get: (name) => lower.get(name.toLowerCase()) ?? null };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The allow-listed headers, and nothing else, as a small record. This is
|
|
75
|
+
* the whole of what any part of foldrun keeps from an upstream response's
|
|
76
|
+
* headers — the proxy calls it at the boundary and passes the result on.
|
|
77
|
+
*/
|
|
78
|
+
export function captureRefusalHeaders(headers) {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const name of REFUSAL_HEADERS) {
|
|
81
|
+
const value = headers.get(name);
|
|
82
|
+
if (typeof value === "string" && value.trim())
|
|
83
|
+
out[name] = value.trim().slice(0, 120);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Which of the two windows refused, when either did. */
|
|
88
|
+
function rejectedWindow(h) {
|
|
89
|
+
for (const w of ["5h", "7d"]) {
|
|
90
|
+
if ((h[`${UNIFIED_PREFIX}-${w}-status`] ?? "").toLowerCase() === "rejected")
|
|
91
|
+
return w;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** The busiest window we were told about, when none of them said
|
|
96
|
+
* "rejected" outright but the request was refused anyway. */
|
|
97
|
+
function busiestWindow(h) {
|
|
98
|
+
let best = null;
|
|
99
|
+
let high = -1;
|
|
100
|
+
for (const w of ["5h", "7d"]) {
|
|
101
|
+
const raw = h[`${UNIFIED_PREFIX}-${w}-utilization`];
|
|
102
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
103
|
+
if (Number.isFinite(n) && n > high) {
|
|
104
|
+
high = n;
|
|
105
|
+
best = w;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return best;
|
|
109
|
+
}
|
|
110
|
+
const WINDOW_WORD = { "5h": "5-hour", "7d": "7-day" };
|
|
111
|
+
/** `0.35` → `35%`. Null for anything that is not a number we can show. */
|
|
112
|
+
function percent(raw) {
|
|
113
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
114
|
+
if (!Number.isFinite(n) || n < 0)
|
|
115
|
+
return null;
|
|
116
|
+
// Providers have sent both a fraction and a percentage. Over 1 can only
|
|
117
|
+
// be the latter; a run should not be told it is 3500% through a window.
|
|
118
|
+
const fraction = n > 1 ? n / 100 : n;
|
|
119
|
+
return `${Math.round(fraction * 100)}%`;
|
|
120
|
+
}
|
|
121
|
+
/** The short name a person uses for a zone: `Australia/Sydney` → Sydney,
|
|
122
|
+
* `UTC` → UTC, `+10:00` → UTC+10:00. */
|
|
123
|
+
export function zoneWord(timezone) {
|
|
124
|
+
if (!timezone)
|
|
125
|
+
return "UTC";
|
|
126
|
+
if (/^[+-]/.test(timezone))
|
|
127
|
+
return `UTC${timezone}`;
|
|
128
|
+
const last = timezone.split("/").pop() ?? timezone;
|
|
129
|
+
return last.replace(/_/g, " ");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A unix-second reset as a person in that zone reads it: `14:00 Sydney`,
|
|
133
|
+
* with the date when it is not today there. Null for a stamp that is not
|
|
134
|
+
* one, so a garbled header costs a clause rather than the sentence.
|
|
135
|
+
*/
|
|
136
|
+
export function resetWhen(raw, timezone, now = new Date()) {
|
|
137
|
+
const secs = raw === undefined ? NaN : Number(raw);
|
|
138
|
+
if (!Number.isFinite(secs) || secs <= 0)
|
|
139
|
+
return null;
|
|
140
|
+
// Seconds, or milliseconds from a provider that forgot which it promised.
|
|
141
|
+
const at = new Date(secs > 1e11 ? secs : secs * 1000);
|
|
142
|
+
if (Number.isNaN(at.getTime()))
|
|
143
|
+
return null;
|
|
144
|
+
let time;
|
|
145
|
+
try {
|
|
146
|
+
time = new Intl.DateTimeFormat("en-GB", {
|
|
147
|
+
timeZone: timezone,
|
|
148
|
+
hour: "2-digit",
|
|
149
|
+
minute: "2-digit",
|
|
150
|
+
hour12: false,
|
|
151
|
+
}).format(at);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const sameDay = localDate(timezone, at) === localDate(timezone, now);
|
|
157
|
+
if (sameDay)
|
|
158
|
+
return `${time} ${zoneWord(timezone)}`;
|
|
159
|
+
const day = new Intl.DateTimeFormat("en-GB", {
|
|
160
|
+
timeZone: timezone,
|
|
161
|
+
weekday: "short",
|
|
162
|
+
day: "numeric",
|
|
163
|
+
month: "short",
|
|
164
|
+
}).format(at);
|
|
165
|
+
return `${time} ${zoneWord(timezone)} on ${day}`;
|
|
166
|
+
}
|
|
167
|
+
/** `retry-after: 90` → `90s`; `180` → `3m`. */
|
|
168
|
+
function waitWord(raw) {
|
|
169
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
170
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
171
|
+
// A provider may send an HTTP date instead of a delta.
|
|
172
|
+
const at = raw ? Date.parse(raw) : NaN;
|
|
173
|
+
if (!Number.isFinite(at))
|
|
174
|
+
return null;
|
|
175
|
+
const secs = Math.round((at - Date.now()) / 1000);
|
|
176
|
+
return secs > 0 ? waitWord(String(secs)) : null;
|
|
177
|
+
}
|
|
178
|
+
if (n < 90)
|
|
179
|
+
return `${Math.round(n)}s`;
|
|
180
|
+
if (n < 5400)
|
|
181
|
+
return `${Math.round(n / 60)}m`;
|
|
182
|
+
return `${(n / 3600).toFixed(1).replace(/\.0$/, "")}h`;
|
|
183
|
+
}
|
|
184
|
+
/** What the provider said it is out of. `out_of_credits` reads as prose. */
|
|
185
|
+
function reasonWord(raw) {
|
|
186
|
+
if (!raw)
|
|
187
|
+
return null;
|
|
188
|
+
return raw.replace(/[_-]+/g, " ").trim().toLowerCase() || null;
|
|
189
|
+
}
|
|
190
|
+
/** The clause that says whether there is anywhere else to go. Always on a
|
|
191
|
+
* refusal the runner finalises: the absence of a fallback is a state to
|
|
192
|
+
* state, not a silence to notice. */
|
|
193
|
+
export function supplyNote(state) {
|
|
194
|
+
if (state === "none")
|
|
195
|
+
return " — no second supply is configured";
|
|
196
|
+
if (state === "trying")
|
|
197
|
+
return " — trying the second supply";
|
|
198
|
+
if (state === "exhausted")
|
|
199
|
+
return " — the second supply refused it too";
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The sentence. One line, this project's voice, safe to put in a trace and
|
|
204
|
+
* on a failure line.
|
|
205
|
+
*
|
|
206
|
+
* Three shapes, in the order they are told apart:
|
|
207
|
+
*
|
|
208
|
+
* 1. rate-limit headers present → a supply problem. Which window, how
|
|
209
|
+
* used, whether overage could have covered it and why it did not, and
|
|
210
|
+
* when it clears in the step's own zone.
|
|
211
|
+
* 2. no rate-limit headers and a 401/403 → a credential problem. Said
|
|
212
|
+
* explicitly, including that this is NOT quota, because assuming the
|
|
213
|
+
* opposite is what cost an hour.
|
|
214
|
+
* 3. anything else → the status, plainly, plus whatever the provider
|
|
215
|
+
* asked us to wait.
|
|
216
|
+
*/
|
|
217
|
+
export function explainRefusal(ctx) {
|
|
218
|
+
const h = ctx.headers ?? {};
|
|
219
|
+
const zone = ctx.timezone || "UTC";
|
|
220
|
+
const now = ctx.now ?? new Date();
|
|
221
|
+
const where = ctx.provider ? ` at ${ctx.provider}` : "";
|
|
222
|
+
const supply = supplyNote(ctx.supply);
|
|
223
|
+
const hasLimits = Object.keys(h).some((k) => k.startsWith(UNIFIED_PREFIX));
|
|
224
|
+
if (hasLimits) {
|
|
225
|
+
const window = rejectedWindow(h) ?? busiestWindow(h);
|
|
226
|
+
const used = window ? percent(h[`${UNIFIED_PREFIX}-${window}-utilization`]) : null;
|
|
227
|
+
const reset = resetWhen(window ? h[`${UNIFIED_PREFIX}-${window}-reset`] : undefined, zone, now) ??
|
|
228
|
+
resetWhen(h[`${UNIFIED_PREFIX}-reset`], zone, now);
|
|
229
|
+
const overage = (h[`${UNIFIED_PREFIX}-overage-status`] ?? "").toLowerCase();
|
|
230
|
+
const why = reasonWord(h[`${UNIFIED_PREFIX}-overage-disabled-reason`]);
|
|
231
|
+
const parts = [];
|
|
232
|
+
if (window) {
|
|
233
|
+
parts.push(used ? `the ${WINDOW_WORD[window]} window is ${used} used` : `the ${WINDOW_WORD[window]} window is spent`);
|
|
234
|
+
}
|
|
235
|
+
if (overage === "rejected" || overage === "disabled" || why) {
|
|
236
|
+
parts.push(why ? `overage is off (${why})` : "overage is off");
|
|
237
|
+
}
|
|
238
|
+
else if (overage === "allowed") {
|
|
239
|
+
parts.push("overage is on but did not cover it");
|
|
240
|
+
}
|
|
241
|
+
// "used BUT overage is off" — the two clauses are in tension, and that
|
|
242
|
+
// tension is the finding: there was room left and no way to buy it.
|
|
243
|
+
const wait = waitWord(h["retry-after"]);
|
|
244
|
+
const tail = reset ? `it resets at ${reset}` : wait ? `it asked us to wait ${wait}` : "";
|
|
245
|
+
const body = parts.length ? parts.join(" but ") : `HTTP ${ctx.status}`;
|
|
246
|
+
return `the subscription${where} refused this burst — ${body}${tail ? `; ${tail}` : ""}${supply}`;
|
|
247
|
+
}
|
|
248
|
+
if (ctx.status === 401 || ctx.status === 403) {
|
|
249
|
+
return (`the credential${where} was refused (HTTP ${ctx.status}) and the response carried no rate-limit headers, ` +
|
|
250
|
+
`so this is the key itself — invalid, revoked, or wrong for this endpoint — not a spent window${supply}`);
|
|
251
|
+
}
|
|
252
|
+
if (ctx.status === 429) {
|
|
253
|
+
const wait = waitWord(h["retry-after"]);
|
|
254
|
+
return wait
|
|
255
|
+
? `the provider${where} is rate limiting this step (HTTP 429) and asked us to wait ${wait}${supply}`
|
|
256
|
+
: `the provider${where} is rate limiting this step (HTTP 429) and named no wait${supply}`;
|
|
257
|
+
}
|
|
258
|
+
if (ctx.status === 402) {
|
|
259
|
+
return `the account${where} is out of credit (HTTP 402) and the response said nothing about windows${supply}`;
|
|
260
|
+
}
|
|
261
|
+
return `the model supply${where} refused this step (HTTP ${ctx.status})${supply}`;
|
|
262
|
+
}
|
|
263
|
+
/** The same sentence, marked, for a log line the runner reads back. */
|
|
264
|
+
export function refusalLine(ctx) {
|
|
265
|
+
return `${REFUSAL_MARK} ${explainRefusal(ctx)}`;
|
|
266
|
+
}
|
|
267
|
+
/** Is this status one we explain? The set the runner already treats as a
|
|
268
|
+
* provider refusal, plus 402 which is what started all this. */
|
|
269
|
+
export function isRefusalStatus(status) {
|
|
270
|
+
return status === 401 || status === 402 || status === 403 || status === 429;
|
|
271
|
+
}
|
|
272
|
+
/** Pull the sentence back out of a trace line that carries the mark,
|
|
273
|
+
* wherever in the line it sits — the translator's lines are prefixed on
|
|
274
|
+
* their way into the record, and the egress proxy's are not. */
|
|
275
|
+
export function refusalFromLine(text) {
|
|
276
|
+
const at = text.indexOf(REFUSAL_MARK);
|
|
277
|
+
if (at < 0)
|
|
278
|
+
return null;
|
|
279
|
+
const said = text.slice(at + REFUSAL_MARK.length).trim();
|
|
280
|
+
return said || null;
|
|
281
|
+
}
|
|
@@ -4,8 +4,11 @@ import type { EventExtra } from "./step-exec.ts";
|
|
|
4
4
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
5
5
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
6
6
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
7
|
+
import type { SubagentSpec } from "./subagents.ts";
|
|
8
|
+
import { type OperatorEvent } from "./operator.ts";
|
|
7
9
|
import type { SearchRoot, RunDigest } from "./context-tools.ts";
|
|
8
10
|
import type { TranslatorSpec } from "./translator.ts";
|
|
11
|
+
import { type BrowserPodOutcome } from "./browser-pod.ts";
|
|
9
12
|
/** What crosses the boundary. Everything in here is values — pre-resolved
|
|
10
13
|
* secrets in headers, assembled prompt, serializable MCP configs. */
|
|
11
14
|
export interface ContainerStepInput {
|
|
@@ -29,6 +32,8 @@ export interface ContainerStepInput {
|
|
|
29
32
|
* their agent.md files host-side. The driver rebuilds the consult tools
|
|
30
33
|
* in here; the callees run toolless, so nothing else need cross. */
|
|
31
34
|
consults: ConsultSpec[];
|
|
35
|
+
/** `subagents:` — built host-side (runner.ts), plain JSON. */
|
|
36
|
+
subagents?: SubagentSpec[];
|
|
32
37
|
timeoutSec?: number;
|
|
33
38
|
/** The step's spend ceiling and the model's per-token price — see
|
|
34
39
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
@@ -44,6 +49,10 @@ export interface ContainerStepInput {
|
|
|
44
49
|
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
45
50
|
schema?: Record<string, unknown> | boolean;
|
|
46
51
|
maxTurns?: number;
|
|
52
|
+
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
53
|
+
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
54
|
+
limits?: Record<string, number>;
|
|
55
|
+
toolOwners?: Record<string, string>;
|
|
47
56
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
48
57
|
* container (/workspace/…, /library/…). */
|
|
49
58
|
search?: SearchRoot[];
|
|
@@ -51,10 +60,21 @@ export interface ContainerStepInput {
|
|
|
51
60
|
history?: RunDigest[];
|
|
52
61
|
/** tools: [desks] — the account's other workspaces' runs, gathered host-side. */
|
|
53
62
|
desks?: RunDigest[];
|
|
63
|
+
/** tools: [ask] — ask_person, and how long it may wait for an answer. */
|
|
64
|
+
ask?: {
|
|
65
|
+
timeoutSec: number;
|
|
66
|
+
};
|
|
54
67
|
/** A Chat-Completions provider: the driver starts the runtime's translator
|
|
55
68
|
* on loopback and points the SDK at it. Null or absent: the env's
|
|
56
69
|
* ANTHROPIC_BASE_URL is spoken to directly. */
|
|
57
70
|
translator?: TranslatorSpec | null;
|
|
71
|
+
/** A granted tool acts outward (`outward: true`): an executor that could
|
|
72
|
+
* put a browsing step on the slim image keeps this one on the full image,
|
|
73
|
+
* where a browser in the step is always there (browser-pod.ts). */
|
|
74
|
+
outward?: boolean;
|
|
75
|
+
/** "full" — this attempt must have the full image: the re-run after the
|
|
76
|
+
* browser pod was lost. Absent lets the executor choose. */
|
|
77
|
+
image?: "full";
|
|
58
78
|
}
|
|
59
79
|
/**
|
|
60
80
|
* How long the sandbox actually existed, measured host-side — the driver
|
|
@@ -96,11 +116,25 @@ export interface ContainerStepOutcome {
|
|
|
96
116
|
inputTokens: number;
|
|
97
117
|
outputTokens: number;
|
|
98
118
|
} | null;
|
|
119
|
+
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
120
|
+
* see ExecOutcome.turnsCostUsd. */
|
|
121
|
+
turnsCostUsd?: number;
|
|
99
122
|
/** What the sandbox actually touched — see the driver's readActuals. */
|
|
100
123
|
res?: StepActuals | null;
|
|
101
124
|
/** Set by the isolated executors only — an in-process step rents no
|
|
102
125
|
* sandbox, so it has no compute seconds to bill. */
|
|
103
126
|
timing?: StepTiming | null;
|
|
127
|
+
/** Which runner image the sandbox ran, as the executor chose it: `pod`
|
|
128
|
+
* when a browsing step ran on slim and browsed through the account's
|
|
129
|
+
* browser pod. Absent from an executor with one image. */
|
|
130
|
+
image?: {
|
|
131
|
+
variant: "full" | "slim";
|
|
132
|
+
why?: string;
|
|
133
|
+
pod?: boolean;
|
|
134
|
+
} | null;
|
|
135
|
+
/** The pod's reconnects and, when it was lost, what the step had written —
|
|
136
|
+
* see ExecOutcome.browserPod. */
|
|
137
|
+
browserPod?: BrowserPodOutcome;
|
|
104
138
|
}
|
|
105
139
|
/**
|
|
106
140
|
* The one place the timing line is worded, so the docker and k8s traces
|
|
@@ -135,6 +169,17 @@ export declare function allowedBack(rel: string): boolean;
|
|
|
135
169
|
* step changed without the staging copy the first driver held in /tmp.
|
|
136
170
|
*/
|
|
137
171
|
export declare function hashTree(dir: string): Record<string, string>;
|
|
172
|
+
/**
|
|
173
|
+
* Every file's mtime under `dir`, relative path to milliseconds — so a copy
|
|
174
|
+
* that cannot carry times can have them put back. `kubectl cp` unpacks with
|
|
175
|
+
* `tar -m`, which stamps every file with the moment it landed; on 2026-09-24
|
|
176
|
+
* gbp-desk's reply-sheet check ("pending-replies.json written in the last
|
|
177
|
+
* hour") passed on a file last written on 2026-09-13, and the poster sent
|
|
178
|
+
* nothing. Every freshness check in every flow was answering "yes".
|
|
179
|
+
*/
|
|
180
|
+
export declare function mtimeManifest(dir: string): Record<string, number>;
|
|
181
|
+
/** The in-pod half of mtimeManifest: a node one-liner, run as root before `go`. */
|
|
182
|
+
export declare const APPLY_MTIMES_JS: string;
|
|
138
183
|
/**
|
|
139
184
|
* Both sides changed a file the step was handed. When each is the handed-in
|
|
140
185
|
* content with lines added on the end — a ledger, history.md, a .jsonl — the
|
|
@@ -157,22 +202,79 @@ export declare function applyContainerChanges(hostWs: string, containerWs: strin
|
|
|
157
202
|
*/
|
|
158
203
|
export declare const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
|
|
159
204
|
/**
|
|
160
|
-
*
|
|
205
|
+
* Where the platform's shared runtime layer is mounted, read-only, in a run
|
|
206
|
+
* pod — and read-write, one entry at a time, in the pod that builds an entry
|
|
207
|
+
* for it (foldrun-platform runtime-warm.ts). A fixed path for the same reason
|
|
208
|
+
* as RUNTIME_CACHE: a venv hard-codes its own location. Laid out as
|
|
209
|
+
* <FOLDRUN_DATA>/shared/.runtimes so the builder's prepareRuntime("shared", …)
|
|
210
|
+
* lands exactly here with FOLDRUN_DATA=/opt/foldrun-runtimes.
|
|
211
|
+
*/
|
|
212
|
+
export declare const SHARED_RUNTIMES = "/opt/foldrun-runtimes/shared/.runtimes";
|
|
213
|
+
/**
|
|
214
|
+
* The per-tenant runtime cache mount — a Docker NAMED volume and the fixed
|
|
215
|
+
* path it mounts at — or null when there is none.
|
|
216
|
+
*
|
|
217
|
+
* A named volume, not a host path. The run's `docker create` is issued to the
|
|
218
|
+
* host daemon over the mounted socket, and that daemon cannot resolve a path
|
|
219
|
+
* under the platform's own FOLDRUN_DATA — which under compose is itself a named
|
|
220
|
+
* volume, or a directory Docker Desktop has not shared — so `-v <hostpath>:…`
|
|
221
|
+
* failed with "mounts denied". A named volume is resolved by the daemon, needs
|
|
222
|
+
* no host sharing, and on first use is seeded from the image's mount point,
|
|
223
|
+
* which the runner image creates owned by the agent user (the DOCKERFILE below,
|
|
224
|
+
* `${RUNTIME_CACHE}`) — so the fresh cache is writable without a host-side chown.
|
|
161
225
|
*
|
|
162
|
-
* Per-tenant, not shared. A shared cache would store one copy of pandas
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
226
|
+
* Per-tenant, not shared. A shared cache would store one copy of pandas instead
|
|
227
|
+
* of one per account, but it would also be mutable state that one tenant's step
|
|
228
|
+
* writes and another tenant's step then *executes* — a poisoned venv arriving
|
|
229
|
+
* as a trusted local file, which is precisely the path gVisor, the capability
|
|
230
|
+
* drop and the egress policy all exist to close. Disk is the cheaper thing to
|
|
231
|
+
* spend. One volume per tenant keeps that boundary; each carries a
|
|
232
|
+
* `foldrun.io/tenant` label so it can be found and pruned (self-hosting/README.md).
|
|
169
233
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* Two roots, so a hit is always a real hit.
|
|
234
|
+
* The venvs inside carry the runner's own absolute paths, distinct from the
|
|
235
|
+
* host's `<tenant>/.runtimes` cache — two roots, so a hit is always a real hit.
|
|
173
236
|
*/
|
|
174
|
-
export declare function
|
|
175
|
-
|
|
237
|
+
export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
238
|
+
source: string;
|
|
239
|
+
target: string;
|
|
240
|
+
} | null;
|
|
241
|
+
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
242
|
+
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
243
|
+
export declare const UV_VERSION = "0.11.25";
|
|
244
|
+
/** How many times a best-effort engine download is tried before the build
|
|
245
|
+
* gives up on it, and the wait before the second and third (15s, then 30s).
|
|
246
|
+
* A GitHub release or Google's apt mirror that blinks for a minute used to
|
|
247
|
+
* cost the engine outright; on x86_64 that now fails the build, so a
|
|
248
|
+
* transient blip would fail a deploy. */
|
|
249
|
+
export declare const ENGINE_DOWNLOAD_ATTEMPTS = 3;
|
|
250
|
+
/** `retry <engine> <command…>`: one shell function, defined at the top of each
|
|
251
|
+
* RUN that downloads an engine (a RUN is its own shell). Its lines start
|
|
252
|
+
* "runner image:" like every engine line, and go to stderr so a quiet
|
|
253
|
+
* install (>/dev/null) still shows them — the deploy greps them into the
|
|
254
|
+
* step summary (buildFailureSummary). */
|
|
255
|
+
export declare const RETRY_SH = "retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; }";
|
|
256
|
+
/** The base both targets start from, pinned by digest. The tag alone floats:
|
|
257
|
+
* `node:22-slim` is re-pushed for every Node patch and Debian point release,
|
|
258
|
+
* and will change Debian release when the Node image decides to, so the
|
|
259
|
+
* same runner tag — a hash of this text — could hold different bytes on two
|
|
260
|
+
* rebuilds. With the digest in the text, new base bytes mean a new tag.
|
|
261
|
+
*
|
|
262
|
+
* Debian 13 (trixie), not the 12 that `node:22-slim` still names: its glibc
|
|
263
|
+
* (2.41) runs Lightpanda past 0.3.6, which wants 2.38; Debian 12 has 2.36.
|
|
264
|
+
* Python is 3.13 here (3.11 on 12): runtime.ts checkEntry rebuilds a cached
|
|
265
|
+
* venv whose interpreter moved, once per declaration.
|
|
266
|
+
*
|
|
267
|
+
* Raise it on purpose (foldrun-infra/README.md, "Runner images"): read the
|
|
268
|
+
* current digest of the tag, put it here, build both targets on both arches
|
|
269
|
+
* and make one browse call per engine on the full image:
|
|
270
|
+
* docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
|
|
271
|
+
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
272
|
+
* arm64 alike. */
|
|
273
|
+
export declare const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
|
|
274
|
+
export declare const DOCKERFILE = "FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 AS base\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\\n && rm -rf /var/lib/apt/lists/* \\\n && useradd -m -u 10001 agent\n# uv builds the declared python environments (runtime.ts): a fraction of\n# pip's time, and it can fetch a pinned python the image does not carry.\nCOPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /usr/local/bin/\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules\n\nFROM base AS slim\n# Fonts, because a step that draws text without a browser still needs them:\n# the full image gets 51 from Playwright's --with-deps, slim had none, so from\n# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram\n# rendered its labels as empty boxes \u2014 expert-witness-desk's validator failed\n# four passes on a post image that was \"three empty boxes\". DejaVu covers\n# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.\n# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and\n# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp\n# 0.34), so four more, the smallest set that drew all of them \u2014 8 MB:\n# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):\n# resvg draws nothing from its bitmap glyphs, so a resvg\n# diagram kept its empty boxes.\n# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.\n# Lohit Devanagari, Loma Hindi and Thai.\n# Here, not in base, so the full image's browser layers stay cached.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\\n && rm -rf /var/lib/apt/lists/* \\\n && fc-cache -f >/dev/null\n# The Playwright client only, the same pinned version as the browser pod's\n# server (the wire protocol is version-locked), so a tool that imports it\n# still loads; there is nothing here for it to launch.\n# npm's download cache is dropped in the same layer: it was 190 MB of every\n# image, read by nothing after the build.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz driver.mjs entry.sh ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n\nFROM base AS browsers\n# Real browsers, because directories and portals increasingly render with\n# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the\n# browser tool drives by default; Firefox and WebKit (Safari's engine) are\n# there for an agent asked to see a page the way those render it. Installed\n# to a fixed path the agent user can read (the default cache would be\n# root's HOME). The browsers' own sandboxes are disabled at launch time \u2014 in\n# this platform the container/gVisor IS the sandbox, and the two fight.\n# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's\n# \"ai\" mode (1.59+), and the per-account browser pod runs this same image, so\n# a floating version would move both under a working tool on any rebuild.\n# Raise it on purpose, with the gallery tests and one real call.\n# axe-core beside it is web browse's mode=a11y: one file the tool injects\n# into the page; without it the mode falls back to its own shorter checks.\n# Xvfb by name: the browser pod starts it at boot and web browse starts one\n# per call for headless: false. It used to arrive only as one of Playwright's\n# --with-deps packages, so a Playwright bump that dropped it would have\n# broken every windowed browser without a word. No xauth: nothing here runs\n# xvfb-run, and Xvfb started without -auth takes the local client as it is.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends xvfb \\\n && rm -rf /var/lib/apt/lists/*\n# Google Chrome and Chrome beta come from Google's apt repository, which\n# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on\n# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\\n && playwright install --with-deps chromium firefox webkit >/dev/null \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && if [ \"$(uname -m)\" = x86_64 ]; then \\\n retry chrome playwright install --with-deps chrome >/dev/null || echo \"runner image: chrome not installed - engine: chrome would fall back to chromium\" >&2; \\\n retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo \"runner image: chrome-beta not installed\" >&2; \\\n else echo \"runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium\"; fi \\\n && chmod -R a+rX /opt/browser \\\n && npm cache clean --force >/dev/null 2>&1\n# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and\n# never draws \u2014 a fraction of Chromium's memory for reading and filling\n# pages. Its own program, which web browse starts beside a call and drives\n# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by\n# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the\n# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it\n# at 0.3.6. An arch without a build leaves the engine out, and the tool says\n# so when asked for it. Each download (with its checksum) is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.\n# Raise it on purpose: the new release's two sha256s, then one\n# engine=lightpanda call on each arch.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\\n aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/lightpanda \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda \"https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux\" \\\n && echo \"$sum /opt/browser/lightpanda/lightpanda\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry lightpanda fetch; then \\\n chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\\n else rm -f /opt/browser/lightpanda/lightpanda; echo \"runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked\" >&2; fi\n# Obscura (engine: obscura): a headless browser in Rust around V8 that does\n# draw \u2014 screenshots, a raster PDF, a screencast \u2014 light like Lightpanda.\n# Apache-2.0. The plain build, not -stealth: this platform does not help a\n# page not see automation. Two binaries, obscura and obscura-worker, which\n# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so\n# Debian 13 (2.41) runs it. As above, a download is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\\n aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/obscura \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /tmp/obscura.tgz \"https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz\" \\\n && echo \"$sum /tmp/obscura.tgz\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry obscura fetch \\\n && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\\n chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\\n else rm -rf /opt/browser/obscura/*; echo \"runner image: obscura not installed for $arch - engine: obscura says so when asked\" >&2; fi \\\n && rm -f /tmp/obscura.tgz\n# What this image actually has, written down: /opt/browser/engines.json, one\n# entry per engine (its path and version, or null when the install above\n# left it out) plus Xvfb. Chromium's version is its own --version (dotted,\n# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine\n# chromium is compared with it, not with Google Chrome's) then the build.\n# web browse reads it for its \"built without\" answer, and the deploy reads\n# it to report the engines beside the version.\n# On x86_64 a missing engine FAILS the build: every engine ships for that\n# arch, so a gap there is a failed download, and an image that quietly lacks\n# the Chrome or Lightpanda a desk names would fail those runs instead. A\n# failed build leaves the previous image running and says why. Elsewhere\n# (arm64 has no Google Chrome) the gap is recorded and the image ships.\nRUN node -e ' \\\n const fs = require(\"fs\"), cp = require(\"child_process\"), pw = require(\"playwright\"); \\\n const at = (p) => (p && fs.existsSync(p) ? p : null); \\\n const ver = (bin, args) => { try { return cp.execFileSync(bin, args, { timeout: 30000, stdio: [\"ignore\", \"pipe\", \"ignore\"] }).toString().trim().split(\"\\n\")[0] || null; } catch { return null; } }; \\\n const one = (p, v) => (p ? { path: p, version: v } : null); \\\n const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\\n const lp = at(\"/opt/browser/lightpanda/lightpanda\"); \\\n const cr = at(pw.chromium.executablePath()); \\\n const ob = at(\"/opt/browser/obscura/obscura-worker\") && at(\"/opt/browser/obscura/obscura\"); \\\n const engines = { \\\n chromium: one(cr, cr && [ver(cr, [\"--version\"]), pwBuild(cr)].filter(Boolean).join(\" \")), \\\n chrome: one(at(\"/opt/google/chrome/chrome\"), ver(\"/opt/google/chrome/chrome\", [\"--version\"])), \\\n \"chrome-beta\": one(at(\"/opt/google/chrome-beta/chrome\"), ver(\"/opt/google/chrome-beta/chrome\", [\"--version\"])), \\\n firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\\n webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\\n lightpanda: one(lp, lp && ver(lp, [\"version\"])), \\\n obscura: one(ob, ob && ver(ob, [\"--version\"])), \\\n }; \\\n const xvfb = ver(\"sh\", [\"-c\", \"command -v Xvfb\"]); \\\n const arch = cp.execFileSync(\"uname\", [\"-m\"]).toString().trim(); \\\n const manifest = { arch, playwright: require(\"playwright/package.json\").version, engines, xvfb }; \\\n fs.writeFileSync(\"/opt/browser/engines.json\", JSON.stringify(manifest, null, 2) + \"\\n\"); \\\n console.log(JSON.stringify(manifest)); \\\n const missing = [\"chromium\", \"chrome\", \"firefox\", \"webkit\", \"lightpanda\", \"obscura\"].filter((e) => !engines[e]).concat(xvfb ? [] : [\"Xvfb\"]); \\\n if (missing.length && arch === \"x86_64\") { \\\n console.error(\"runner image: MISSING ENGINES on x86_64: \" + missing.join(\", \")); \\\n console.error(\"runner image: every engine ships for x86_64, so a missing one is a failed download (the retry lines above say which) - failing the build\"); \\\n process.exit(1); \\\n } \\\n if (missing.length) console.error(\"runner image: built without \" + missing.join(\", \") + \" on \" + arch); \\\n ' \\\n && chmod a+r /opt/browser/engines.json\n\nFROM browsers AS full\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz driver.mjs entry.sh ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n";
|
|
275
|
+
/** Which build target of the runner image (see DOCKERFILE). */
|
|
276
|
+
export type RunnerVariant = "full" | "slim";
|
|
277
|
+
export declare function runnerImageTag(variant?: RunnerVariant): string;
|
|
176
278
|
/**
|
|
177
279
|
* Build the runner image if this exact (core version, driver, dockerfile)
|
|
178
280
|
* has not been built before. `npm pack` of the installed core, so the image
|
|
@@ -186,12 +288,43 @@ export declare function runnerImageTag(): string;
|
|
|
186
288
|
* (tonistiigi/binfmt); the publisher does that itself, every time, because
|
|
187
289
|
* the registration does not survive a reboot.
|
|
188
290
|
*/
|
|
291
|
+
/**
|
|
292
|
+
* Which image a step runs in: an explicit FOLDRUN_RUNNER_IMAGE when one is
|
|
293
|
+
* set, otherwise the content-hash tag core builds itself. `explicit` is what
|
|
294
|
+
* ensureRunnerImage checks to skip the build — an empty string is *not* an
|
|
295
|
+
* image name (it is `docker build -t ""` → "repository name must have at
|
|
296
|
+
* least one component"), so it falls through to the content-hash tag exactly
|
|
297
|
+
* as an unset value does. Pure, so the resolution is testable without docker.
|
|
298
|
+
*/
|
|
299
|
+
export declare function runnerImageRef(opts?: {
|
|
300
|
+
platform?: string;
|
|
301
|
+
variant?: RunnerVariant;
|
|
302
|
+
}): {
|
|
303
|
+
tag: string;
|
|
304
|
+
explicit: boolean;
|
|
305
|
+
};
|
|
189
306
|
export declare function ensureRunnerImage(opts?: {
|
|
190
307
|
platform?: string;
|
|
308
|
+
variant?: RunnerVariant;
|
|
191
309
|
}): {
|
|
192
310
|
tag: string;
|
|
193
311
|
log: string[];
|
|
194
312
|
};
|
|
313
|
+
/**
|
|
314
|
+
* What a failed runner build's error says: the lines that name the failure,
|
|
315
|
+
* then the end of the log. Only the last 2000 characters used to survive,
|
|
316
|
+
* and BuildKit ends a failed RUN by echoing the instruction — the engines
|
|
317
|
+
* probe is a long one — so "chrome missing" scrolled out of the very error
|
|
318
|
+
* that existed to say it. Every engine line the DOCKERFILE prints starts
|
|
319
|
+
* "runner image:" (the retries, the giving up, the MISSING ENGINES line);
|
|
320
|
+
* those are kept, from anywhere in the log, with BuildKit's "#12 3.456 "
|
|
321
|
+
* prefix taken off, and so are BuildKit's own ERROR lines (each cut short:
|
|
322
|
+
* they quote the whole instruction). Pure, so it is tested on fixtures.
|
|
323
|
+
*/
|
|
324
|
+
export declare function buildFailureSummary(log: string, opts?: {
|
|
325
|
+
tail?: number;
|
|
326
|
+
maxLines?: number;
|
|
327
|
+
}): string;
|
|
195
328
|
export declare function parseDriverLine(line: string): {
|
|
196
329
|
e: "event";
|
|
197
330
|
type: "text" | "tool" | "info" | "error";
|
|
@@ -199,6 +332,9 @@ export declare function parseDriverLine(line: string): {
|
|
|
199
332
|
call?: string;
|
|
200
333
|
ms?: number;
|
|
201
334
|
err?: boolean;
|
|
335
|
+
operator?: OperatorEvent;
|
|
336
|
+
subagent?: string;
|
|
337
|
+
check?: boolean;
|
|
202
338
|
} | ({
|
|
203
339
|
e: "done";
|
|
204
340
|
} & ContainerStepOutcome) | null;
|