@foldrun/core 0.5.0 → 0.7.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/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +24 -4
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +31 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +30 -0
- package/dist/src/model-credential.js +98 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +6 -1
- package/dist/src/run-container.js +93 -20
- package/dist/src/runner.d.ts +239 -0
- package/dist/src/runner.js +380 -72
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +16 -0
- package/dist/src/script-tools.js +50 -8
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +22 -2
- package/dist/src/step-exec.js +70 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +24 -0
- package/dist/src/store.js +91 -10
- package/dist/src/subagents.js +1 -1
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +4 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/browser-pod.ts +46 -9
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +28 -3
- package/src/evals.ts +23 -2
- package/src/host-env.ts +32 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +112 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +92 -19
- package/src/runner.ts +385 -72
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +48 -6
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +75 -10
- package/src/storage.ts +13 -0
- package/src/store.ts +91 -9
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +3 -3
- package/templates/hello/agents/notetaker/agent.md +2 -2
package/dist/src/runtime.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export interface RuntimeSpec {
|
|
|
3
3
|
packages: string[];
|
|
4
4
|
node?: string | boolean;
|
|
5
5
|
npm: string[];
|
|
6
|
+
/** Debian packages the step needs from the operating system — a library's
|
|
7
|
+
* headers for a pip package that compiles (`libcairo2-dev`), a C compiler
|
|
8
|
+
* (`build-essential`), a program (`ffmpeg`). Installed with apt by the run
|
|
9
|
+
* pod's root start-up, before anything model-directed runs, from a .deb
|
|
10
|
+
* cache on the runtime volume (SYSTEM_INSTALL_SH); verified here. */
|
|
11
|
+
system?: string[];
|
|
6
12
|
/** Declared entries that are not valid requirements. Kept so the run can
|
|
7
13
|
* SAY they were dropped: silently discarding `pandas>=2` and then failing
|
|
8
14
|
* the script with "no module named pandas" is the least debuggable outcome
|
|
@@ -13,6 +19,14 @@ export interface RuntimeSpec {
|
|
|
13
19
|
* from the same argv as its packages — and said so in the build log. */
|
|
14
20
|
ignored?: string[];
|
|
15
21
|
}
|
|
22
|
+
export declare const PIP_REQUIREMENT: RegExp;
|
|
23
|
+
/** npm's own shape: an optional @scope, then a name, then an optional @range. */
|
|
24
|
+
export declare const NPM_REQUIREMENT: RegExp;
|
|
25
|
+
/** A Debian package name as policy defines it: lowercase letters, digits,
|
|
26
|
+
* `+`, `-` and `.`, starting with a letter or digit, two or more characters.
|
|
27
|
+
* Like the two above, a guard first: it can never begin with a dash, so it
|
|
28
|
+
* can never become an apt option, and it holds no shell characters. */
|
|
29
|
+
export declare const DEB_PACKAGE: RegExp;
|
|
16
30
|
export declare function parseRuntime(raw: unknown): RuntimeSpec | null;
|
|
17
31
|
/**
|
|
18
32
|
* Several declarations, one environment.
|
|
@@ -90,4 +104,67 @@ export declare function checkEntry(root: string, spec: RuntimeSpec): string | nu
|
|
|
90
104
|
* moment a cache grows — so it costs nothing on the hot path.
|
|
91
105
|
*/
|
|
92
106
|
export declare function pruneRuntimes(cacheRoot: string, keep: string): string[];
|
|
107
|
+
/**
|
|
108
|
+
* An installer's failure, said so that the first line is the cause.
|
|
109
|
+
*
|
|
110
|
+
* Until 11 Oct 2026 a failure was the installer's last 500 characters, and
|
|
111
|
+
* the run log shows a message's first line: a pycairo build that died for
|
|
112
|
+
* want of a C compiler read, in full, `uv pip install failed: files.` — the
|
|
113
|
+
* tail end of a setuptools deprecation notice. The cause was on the last
|
|
114
|
+
* line of 4 KB nobody saw. So: find the cause and the package it hit, say
|
|
115
|
+
* the fix when it is one we know, then the last lines of the log for the rest.
|
|
116
|
+
*/
|
|
117
|
+
export declare function summarizeInstallFailure(what: string, out: string): string;
|
|
118
|
+
/** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
|
|
119
|
+
* runtime spec) — the run pod's and the docker entry's first line. */
|
|
120
|
+
export declare const SYSTEM_FROM_INPUT_SH = "FOLDRUN_SYSTEM_PACKAGES=\"$(node -e 'try{const r=JSON.parse(require(\"fs\").readFileSync(\"/opt/runner/job/input.json\",\"utf8\")).runtime;process.stdout.write(((r&&r.system)||[]).join(\" \"))}catch(e){}')\"";
|
|
121
|
+
/** Where a run container's root start-up records the system install. */
|
|
122
|
+
export declare const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
|
|
123
|
+
export declare const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
|
|
124
|
+
/**
|
|
125
|
+
* The root start-up's half of `runtime: system:` — one shell snippet, used by
|
|
126
|
+
* the k8s run pod, the docker entry and the deploy-time warm pod alike.
|
|
127
|
+
* Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
|
|
128
|
+
* each re-checked against DEB_PACKAGE's shape here too, so nothing else can
|
|
129
|
+
* ride in), installs them with apt as root BEFORE the drop to the agent user,
|
|
130
|
+
* and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
|
|
131
|
+
*
|
|
132
|
+
* The first step to need a package set installs it with apt (the .debs kept
|
|
133
|
+
* in `<cache>/.apt/archives`) and then saves the files those packages added,
|
|
134
|
+
* with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
|
|
135
|
+
* Every later step restores the snapshot instead — measured 11 Oct 2026 in a
|
|
136
|
+
* gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
|
|
137
|
+
* libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
|
|
138
|
+
* through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
|
|
139
|
+
* The key is the package set AND a checksum of the image's own dpkg status,
|
|
140
|
+
* so a new runner image never restores files built against the old one. A
|
|
141
|
+
* snapshot is written under a temporary name and renamed, so two pods
|
|
142
|
+
* building it at once cannot leave a torn one; a restore that fails falls
|
|
143
|
+
* back to apt, and prepareRuntime verifies the result either way. Unused for
|
|
144
|
+
* 30 days, one is pruned. Package lists stay per pod: two pods updating one
|
|
145
|
+
* lists directory on a shared volume would corrupt it.
|
|
146
|
+
*/
|
|
147
|
+
export declare function systemInstallSh(cacheDir: string, logDir?: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* Are the declared system packages on this machine? The install happened in
|
|
150
|
+
* the container's root start-up (systemInstallSh); this only reads the
|
|
151
|
+
* result. Without dpkg (a laptop, macOS) there is nothing to check against
|
|
152
|
+
* and nothing this process could install: say so and carry on.
|
|
153
|
+
*/
|
|
154
|
+
export declare function checkSystemPackages(pkgs: string[]): {
|
|
155
|
+
log: string[];
|
|
156
|
+
error: string | null;
|
|
157
|
+
};
|
|
93
158
|
export declare function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime;
|
|
159
|
+
/** Where the root start-up's install helper listens, inside a run container. */
|
|
160
|
+
export declare const INSTALL_SOCKET = "/opt/runner/job/install.sock";
|
|
161
|
+
/**
|
|
162
|
+
* What a step installs for itself mid-run (the `install` tool) lands under
|
|
163
|
+
* one per-step directory: pip packages in `<dir>/py` (`uv pip install
|
|
164
|
+
* --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
|
|
165
|
+
* on the step's paths from its first turn, so a tool installed by one call is
|
|
166
|
+
* found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
|
|
167
|
+
* run container the directory is the container's own filesystem and goes
|
|
168
|
+
* when the container does.
|
|
169
|
+
*/
|
|
170
|
+
export declare function stepInstallEnv(dir: string, base: Record<string, string | undefined>): Record<string, string>;
|
package/dist/src/runtime.js
CHANGED
|
@@ -43,9 +43,14 @@ import { spawnSync } from "node:child_process";
|
|
|
43
43
|
*/
|
|
44
44
|
const PIP_NAME = String.raw `[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
|
|
45
45
|
const PIP_SPECIFIER = String.raw `(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
|
|
46
|
-
const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
|
|
46
|
+
export const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
|
|
47
47
|
/** npm's own shape: an optional @scope, then a name, then an optional @range. */
|
|
48
|
-
const NPM_REQUIREMENT = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
|
|
48
|
+
export const NPM_REQUIREMENT = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
|
|
49
|
+
/** A Debian package name as policy defines it: lowercase letters, digits,
|
|
50
|
+
* `+`, `-` and `.`, starting with a letter or digit, two or more characters.
|
|
51
|
+
* Like the two above, a guard first: it can never begin with a dash, so it
|
|
52
|
+
* can never become an apt option, and it holds no shell characters. */
|
|
53
|
+
export const DEB_PACKAGE = /^[a-z0-9][a-z0-9+.-]{1,62}$/;
|
|
49
54
|
export function parseRuntime(raw) {
|
|
50
55
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
51
56
|
return null;
|
|
@@ -67,11 +72,14 @@ export function parseRuntime(raw) {
|
|
|
67
72
|
node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
|
|
68
73
|
npm: list(e.npm, NPM_REQUIREMENT),
|
|
69
74
|
};
|
|
75
|
+
const system = list(e.system ?? e.apt, DEB_PACKAGE);
|
|
76
|
+
if (system.length)
|
|
77
|
+
spec.system = system;
|
|
70
78
|
if (rejected.length)
|
|
71
79
|
spec.rejected = rejected;
|
|
72
80
|
if (Array.isArray(e.ignored) && e.ignored.length)
|
|
73
81
|
spec.ignored = e.ignored.map(String);
|
|
74
|
-
return wantsPython(spec) || wantsNode(spec) ? spec : null;
|
|
82
|
+
return wantsPython(spec) || wantsNode(spec) || wantsSystem(spec) ? spec : null;
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
85
|
* Several declarations, one environment.
|
|
@@ -103,6 +111,9 @@ export function mergeRuntimes(...specs) {
|
|
|
103
111
|
node: pick("node"),
|
|
104
112
|
npm: uniq(present.flatMap((s) => s.npm)),
|
|
105
113
|
};
|
|
114
|
+
const system = uniq(present.flatMap((s) => s.system ?? []));
|
|
115
|
+
if (system.length)
|
|
116
|
+
merged.system = system;
|
|
106
117
|
const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
|
|
107
118
|
if (rejected.length)
|
|
108
119
|
merged.rejected = rejected;
|
|
@@ -117,6 +128,11 @@ export function fingerprint(spec) {
|
|
|
117
128
|
packages: [...spec.packages].sort(),
|
|
118
129
|
node: spec.node ?? null,
|
|
119
130
|
npm: [...spec.npm].sort(),
|
|
131
|
+
// Only when declared: adding the key unconditionally would give every
|
|
132
|
+
// environment already cached a new fingerprint and rebuild them all.
|
|
133
|
+
// Part of it when declared, because a venv whose packages compiled
|
|
134
|
+
// against these libraries is not the venv built without them.
|
|
135
|
+
...(spec.system?.length ? { system: [...spec.system].sort() } : {}),
|
|
120
136
|
});
|
|
121
137
|
return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
122
138
|
}
|
|
@@ -210,6 +226,7 @@ function wire(root, spec, note) {
|
|
|
210
226
|
}
|
|
211
227
|
// `python: false` says "not this one" — it used to read as a declaration
|
|
212
228
|
// because it was not undefined, and built a venv nobody asked for.
|
|
229
|
+
const wantsSystem = (s) => (s.system?.length ?? 0) > 0;
|
|
213
230
|
const wantsPython = (s) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
|
|
214
231
|
const wantsNode = (s) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
|
|
215
232
|
/** How long a build may hold the claim, and therefore how long another step
|
|
@@ -524,9 +541,197 @@ function findUv() {
|
|
|
524
541
|
return uvKnown;
|
|
525
542
|
}
|
|
526
543
|
// Build (or reuse) the environment for one runtime declaration.
|
|
544
|
+
/**
|
|
545
|
+
* An installer's failure, said so that the first line is the cause.
|
|
546
|
+
*
|
|
547
|
+
* Until 11 Oct 2026 a failure was the installer's last 500 characters, and
|
|
548
|
+
* the run log shows a message's first line: a pycairo build that died for
|
|
549
|
+
* want of a C compiler read, in full, `uv pip install failed: files.` — the
|
|
550
|
+
* tail end of a setuptools deprecation notice. The cause was on the last
|
|
551
|
+
* line of 4 KB nobody saw. So: find the cause and the package it hit, say
|
|
552
|
+
* the fix when it is one we know, then the last lines of the log for the rest.
|
|
553
|
+
*/
|
|
554
|
+
export function summarizeInstallFailure(what, out) {
|
|
555
|
+
const text = out ?? "";
|
|
556
|
+
const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+$/, "")).filter(Boolean);
|
|
557
|
+
const pkg = /Failed to build `([^`]+)`/.exec(text)?.[1] ??
|
|
558
|
+
/Failed building wheel for ([\w.-]+)/.exec(text)?.[1] ??
|
|
559
|
+
/Building wheel for ([\w.-]+) \(.*\) did not run successfully/.exec(text)?.[1] ??
|
|
560
|
+
/Failed to (?:download|prepare|fetch) `([^`]+)`/.exec(text)?.[1] ??
|
|
561
|
+
null;
|
|
562
|
+
const subject = pkg ? `\`${pkg}\`` : "a package";
|
|
563
|
+
let cause = null;
|
|
564
|
+
let hint = "";
|
|
565
|
+
const header = /fatal error: ([\w./+-]+\.h): No such file/.exec(text);
|
|
566
|
+
const pc = /Package '?([\w.+-]+)'?,? (?:required by [^,]+, )?(?:was )?not found|Dependency "([\w.+-]+)" not found/.exec(text);
|
|
567
|
+
if (/No such file or directory: '[^']*gcc'|unable to execute '[^']*gcc'|\bgcc: (?:command )?not found|command '[^']*gcc' failed|error: command '[^']*\b(?:cc|gcc|c\+\+|g\+\+)' failed|C compiler cannot create executables|no acceptable C compiler/i.test(text)) {
|
|
568
|
+
cause = `${subject} has to be compiled and this runner has no C compiler`;
|
|
569
|
+
hint = " — add `system: [build-essential]` (and the -dev package of any library it builds against) to the runtime, or pin a version that ships a prebuilt wheel";
|
|
570
|
+
}
|
|
571
|
+
else if (/pkg-config(?:: command)? not found|Did not find pkg-config|pkg-config.*No such file/i.test(text)) {
|
|
572
|
+
cause = `${subject} looks for its libraries with pkg-config, which this runner lacks`;
|
|
573
|
+
hint = " — add `system: [pkg-config]` and the library's -dev package to the runtime";
|
|
574
|
+
}
|
|
575
|
+
else if (header && /^Python\.h$/.test(header[1])) {
|
|
576
|
+
cause = `${subject} builds against Python's own C headers, which this runner lacks`;
|
|
577
|
+
hint = " — add `system: [python3-dev, build-essential]` to the runtime";
|
|
578
|
+
}
|
|
579
|
+
else if (pc || header) {
|
|
580
|
+
const lib = pc?.[1] ?? pc?.[2] ?? header[1];
|
|
581
|
+
cause = `${subject} needs the system library ${lib} (its headers) to build`;
|
|
582
|
+
hint = ` — add its Debian -dev package to \`runtime: system:\`${pc ? ` (for ${lib}, usually \`lib${lib.replace(/^lib/, "")}-dev\`)` : ""}, plus build-essential and pkg-config`;
|
|
583
|
+
}
|
|
584
|
+
else if (/E: Unable to locate package (\S+)/.test(text)) {
|
|
585
|
+
cause = `apt has no package \`${/E: Unable to locate package (\S+)/.exec(text)[1]}\``;
|
|
586
|
+
hint = " — check the name on packages.debian.org for trixie";
|
|
587
|
+
}
|
|
588
|
+
else if (/No solution found when resolving dependencies/.test(text)) {
|
|
589
|
+
const i = lines.findIndex((l) => /No solution found/.test(l));
|
|
590
|
+
cause = `the requirements cannot all be satisfied: ${lines.slice(i + 1, i + 3).map((l) => l.replace(/^[\s╰─▶×]+/, "")).join(" ")}`.trim();
|
|
591
|
+
}
|
|
592
|
+
else if (/No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.test(text)) {
|
|
593
|
+
const m = /No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.exec(text);
|
|
594
|
+
cause = `no published version of \`${m[1] ?? m[2] ?? m[3]}\` matches`;
|
|
595
|
+
}
|
|
596
|
+
else if (/npm (?:ERR!|error) code (\S+)/.test(text)) {
|
|
597
|
+
const code = /npm (?:ERR!|error) code (\S+)/.exec(text)[1];
|
|
598
|
+
const detail = lines.find((l) => /npm (?:ERR!|error) (?:404|notarget|E\w+)\b/.test(l) && !/ code /.test(l));
|
|
599
|
+
cause = `npm ${code}${detail ? `: ${detail.replace(/^npm (?:ERR!|error)\s*/, "")}` : ""}`;
|
|
600
|
+
}
|
|
601
|
+
if (!cause) {
|
|
602
|
+
const last = [...lines].reverse().find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "no output";
|
|
603
|
+
cause = (pkg ? `${subject}: ` : "") + last.replace(/^\s*(?:error:|ERROR:|×)\s*/, "");
|
|
604
|
+
}
|
|
605
|
+
const excerpt = lines.slice(-25).join("\n");
|
|
606
|
+
return `${what} failed — ${cause}${hint}\n${excerpt.length > 2000 ? excerpt.slice(-2000) : excerpt}`;
|
|
607
|
+
}
|
|
608
|
+
/** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
|
|
609
|
+
* runtime spec) — the run pod's and the docker entry's first line. */
|
|
610
|
+
export const SYSTEM_FROM_INPUT_SH = `FOLDRUN_SYSTEM_PACKAGES="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync("/opt/runner/job/input.json","utf8")).runtime;process.stdout.write(((r&&r.system)||[]).join(" "))}catch(e){}')"`;
|
|
611
|
+
/** Where a run container's root start-up records the system install. */
|
|
612
|
+
export const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
|
|
613
|
+
export const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
|
|
614
|
+
/**
|
|
615
|
+
* The root start-up's half of `runtime: system:` — one shell snippet, used by
|
|
616
|
+
* the k8s run pod, the docker entry and the deploy-time warm pod alike.
|
|
617
|
+
* Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
|
|
618
|
+
* each re-checked against DEB_PACKAGE's shape here too, so nothing else can
|
|
619
|
+
* ride in), installs them with apt as root BEFORE the drop to the agent user,
|
|
620
|
+
* and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
|
|
621
|
+
*
|
|
622
|
+
* The first step to need a package set installs it with apt (the .debs kept
|
|
623
|
+
* in `<cache>/.apt/archives`) and then saves the files those packages added,
|
|
624
|
+
* with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
|
|
625
|
+
* Every later step restores the snapshot instead — measured 11 Oct 2026 in a
|
|
626
|
+
* gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
|
|
627
|
+
* libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
|
|
628
|
+
* through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
|
|
629
|
+
* The key is the package set AND a checksum of the image's own dpkg status,
|
|
630
|
+
* so a new runner image never restores files built against the old one. A
|
|
631
|
+
* snapshot is written under a temporary name and renamed, so two pods
|
|
632
|
+
* building it at once cannot leave a torn one; a restore that fails falls
|
|
633
|
+
* back to apt, and prepareRuntime verifies the result either way. Unused for
|
|
634
|
+
* 30 days, one is pruned. Package lists stay per pod: two pods updating one
|
|
635
|
+
* lists directory on a shared volume would corrupt it.
|
|
636
|
+
*/
|
|
637
|
+
export function systemInstallSh(cacheDir, logDir = "/opt/runner/job") {
|
|
638
|
+
return `if [ -n "$FOLDRUN_SYSTEM_PACKAGES" ]; then
|
|
639
|
+
SYS=""; for p in $FOLDRUN_SYSTEM_PACKAGES; do case "$p" in -*|*[!a-z0-9+.-]*) ;; *) SYS="$SYS $p";; esac; done
|
|
640
|
+
D=\${FOLDRUN_DPKG_DIR:-/var/lib/dpkg}; R=\${FOLDRUN_SYS_ROOT:-/}
|
|
641
|
+
C=${cacheDir}/.apt; A=$C/archives; S=$C/snapshots
|
|
642
|
+
mkdir -p "$A/partial" "$S" 2>/dev/null || { A=/var/cache/apt/archives; S=""; }
|
|
643
|
+
KEY=v3-$( { printf '%s\\n' $SYS | sort -u; cksum < "$D/status"; } | cksum | tr ' ' '-')
|
|
644
|
+
T0=$(date +%s); MODE=apt; RC=1
|
|
645
|
+
if [ -n "$S" ] && [ -f "$S/$KEY.tar" ] && tar -xf "$S/$KEY.tar" -C "$R" > ${logDir}/system-install.log 2>&1; then
|
|
646
|
+
MODE=snapshot; RC=0; touch "$S/$KEY.tar" 2>/dev/null
|
|
647
|
+
# apt runs ldconfig through dpkg's trigger; a restore must too, or a
|
|
648
|
+
# library the snapshot brought is missing from /etc/ld.so.cache.
|
|
649
|
+
[ "$R" = / ] && ldconfig 2>/dev/null
|
|
650
|
+
else
|
|
651
|
+
ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-before
|
|
652
|
+
touch /tmp/.sys-mark; sleep 1
|
|
653
|
+
{ apt-get -o Acquire::Retries=3 -qq update && DEBIAN_FRONTEND=noninteractive apt-get -o Dir::Cache::Archives="$A" -o Acquire::Retries=3 -o Dpkg::Use-Pty=0 install -y -qq --no-install-recommends $SYS; } > ${logDir}/system-install.log 2>&1
|
|
654
|
+
RC=$?
|
|
655
|
+
if [ $RC -eq 0 ] && [ -n "$S" ]; then
|
|
656
|
+
ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-after
|
|
657
|
+
comm -13 /tmp/.sys-before /tmp/.sys-after > /tmp/.sys-new
|
|
658
|
+
if [ -s /tmp/.sys-new ]; then
|
|
659
|
+
{ cat $(cat /tmp/.sys-new) | sort -u | while read -r f; do if [ -f "$f" ] || [ -L "$f" ]; then echo "$f"; fi; done
|
|
660
|
+
for l in $(cat /tmp/.sys-new); do ls "\${l%.list}".* 2>/dev/null; done
|
|
661
|
+
echo "$D/status"
|
|
662
|
+
# What the packages' install scripts made, which dpkg's lists do not
|
|
663
|
+
# name: update-alternatives links (libblas.so.3 → /etc/alternatives),
|
|
664
|
+
# generated caches and configs. By ctime, not mtime: those links carry
|
|
665
|
+
# the package's own (2025) mtime, and only ctime cannot be backdated.
|
|
666
|
+
find \${FOLDRUN_SYS_SCAN:-/etc /usr /var/lib} -xdev \\( -path /var/lib/apt -o -path /var/lib/dpkg/info \\) -prune -o \\( -type f -o -type l \\) -cnewer /tmp/.sys-mark -print 2>/dev/null
|
|
667
|
+
} | sort -u > /tmp/.sys-files
|
|
668
|
+
tar -cf "$S/.$KEY.$$.tar" -T /tmp/.sys-files 2>/dev/null && mv -f "$S/.$KEY.$$.tar" "$S/$KEY.tar" 2>/dev/null
|
|
669
|
+
rm -f "$S/.$KEY.$$.tar" 2>/dev/null
|
|
670
|
+
fi
|
|
671
|
+
find "$S" -name '*.tar' -mtime +30 -delete 2>/dev/null
|
|
672
|
+
fi
|
|
673
|
+
fi
|
|
674
|
+
printf '{"ok": %s, "exit": %s, "seconds": %s, "mode": "%s", "packages": "%s"}\\n' "$([ $RC -eq 0 ] && echo true || echo false)" "$RC" "$(( $(date +%s) - T0 ))" "$MODE" "$SYS" > ${logDir}/system-install.json
|
|
675
|
+
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
|
|
676
|
+
fi`;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Are the declared system packages on this machine? The install happened in
|
|
680
|
+
* the container's root start-up (systemInstallSh); this only reads the
|
|
681
|
+
* result. Without dpkg (a laptop, macOS) there is nothing to check against
|
|
682
|
+
* and nothing this process could install: say so and carry on.
|
|
683
|
+
*/
|
|
684
|
+
export function checkSystemPackages(pkgs) {
|
|
685
|
+
if (!pkgs.length)
|
|
686
|
+
return { log: [], error: null };
|
|
687
|
+
const statusFile = process.env.FOLDRUN_SYSTEM_STATUS ?? SYSTEM_STATUS_FILE;
|
|
688
|
+
const logFile = process.env.FOLDRUN_SYSTEM_LOG ?? SYSTEM_LOG_FILE;
|
|
689
|
+
let status = null;
|
|
690
|
+
try {
|
|
691
|
+
status = JSON.parse(fs.readFileSync(statusFile, "utf8"));
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
status = null;
|
|
695
|
+
}
|
|
696
|
+
const q = spawnSync("dpkg-query", ["-W", "-f=${Package} ${db:Status-Status}\n", ...pkgs], { encoding: "utf8" });
|
|
697
|
+
if (q.error) {
|
|
698
|
+
return { log: [`system packages ${pkgs.join(", ")} install only in a run container; on this machine they are assumed present`], error: null };
|
|
699
|
+
}
|
|
700
|
+
const installed = new Set((q.stdout ?? "")
|
|
701
|
+
.split("\n")
|
|
702
|
+
.map((l) => l.trim().split(/\s+/))
|
|
703
|
+
.filter((f) => f[1] === "installed")
|
|
704
|
+
.map((f) => f[0].replace(/:.*$/, "")));
|
|
705
|
+
const missing = pkgs.filter((p) => !installed.has(p));
|
|
706
|
+
if (!missing.length) {
|
|
707
|
+
return { log: [`system packages ready: ${pkgs.join(", ")}${status?.seconds !== undefined ? ` (${status.mode === "snapshot" ? "snapshot" : "apt"}, ${status.seconds}s)` : ""}`], error: null };
|
|
708
|
+
}
|
|
709
|
+
let aptOut = "";
|
|
710
|
+
try {
|
|
711
|
+
aptOut = fs.readFileSync(logFile, "utf8");
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
aptOut = "";
|
|
715
|
+
}
|
|
716
|
+
const reason = !status
|
|
717
|
+
? "this runner did not install them — system packages are installed by a run container's start-up (k8s or docker isolation), not on a bare host"
|
|
718
|
+
: status.ok
|
|
719
|
+
? "apt reported success, yet they are not installed"
|
|
720
|
+
: summarizeInstallFailure("apt-get install", aptOut).replace(/^apt-get install failed — /, "apt: ");
|
|
721
|
+
return { log: [], error: `system packages not installed: ${missing.join(", ")} — ${reason}` };
|
|
722
|
+
}
|
|
527
723
|
export function prepareRuntime(tenant, spec) {
|
|
528
724
|
if (!spec)
|
|
529
725
|
return EMPTY;
|
|
726
|
+
const system = checkSystemPackages(spec.system ?? []);
|
|
727
|
+
if (system.error)
|
|
728
|
+
return { ...EMPTY, log: system.log, error: system.error };
|
|
729
|
+
if (!wantsPython(spec) && !wantsNode(spec))
|
|
730
|
+
return { ...EMPTY, log: system.log };
|
|
731
|
+
const built = prepareLanguageRuntime(tenant, spec);
|
|
732
|
+
return system.log.length ? { ...built, log: [...system.log, ...built.log] } : built;
|
|
733
|
+
}
|
|
734
|
+
function prepareLanguageRuntime(tenant, spec) {
|
|
530
735
|
const fp = fingerprint(spec);
|
|
531
736
|
const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
|
|
532
737
|
const shared = path.join(cacheRoot, fp);
|
|
@@ -695,7 +900,7 @@ export function prepareRuntime(tenant, spec) {
|
|
|
695
900
|
? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
|
|
696
901
|
: install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
|
|
697
902
|
if (!installed.ok) {
|
|
698
|
-
return fail(`${uv ? "uv pip" : "pip"} install
|
|
903
|
+
return fail(summarizeInstallFailure(`${uv ? "uv pip" : "pip"} install`, installed.out));
|
|
699
904
|
}
|
|
700
905
|
built.packages = spec.packages;
|
|
701
906
|
log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
|
|
@@ -718,7 +923,7 @@ export function prepareRuntime(tenant, spec) {
|
|
|
718
923
|
npm_config_cache: path.join(root, ".npm-cache"),
|
|
719
924
|
});
|
|
720
925
|
if (!installed.ok) {
|
|
721
|
-
return fail(
|
|
926
|
+
return fail(summarizeInstallFailure("npm install", installed.out));
|
|
722
927
|
}
|
|
723
928
|
built.npm = spec.npm;
|
|
724
929
|
log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
|
|
@@ -790,3 +995,23 @@ function touch(file) {
|
|
|
790
995
|
// read-only or gone; neither is a reason to fail the step
|
|
791
996
|
}
|
|
792
997
|
}
|
|
998
|
+
// ---------- installing mid-step ----------
|
|
999
|
+
/** Where the root start-up's install helper listens, inside a run container. */
|
|
1000
|
+
export const INSTALL_SOCKET = "/opt/runner/job/install.sock";
|
|
1001
|
+
/**
|
|
1002
|
+
* What a step installs for itself mid-run (the `install` tool) lands under
|
|
1003
|
+
* one per-step directory: pip packages in `<dir>/py` (`uv pip install
|
|
1004
|
+
* --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
|
|
1005
|
+
* on the step's paths from its first turn, so a tool installed by one call is
|
|
1006
|
+
* found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
|
|
1007
|
+
* run container the directory is the container's own filesystem and goes
|
|
1008
|
+
* when the container does.
|
|
1009
|
+
*/
|
|
1010
|
+
export function stepInstallEnv(dir, base) {
|
|
1011
|
+
return {
|
|
1012
|
+
// After whatever is already there: a declared environment keeps winning.
|
|
1013
|
+
PATH: [base.PATH ?? "", path.join(dir, "py", "bin"), path.join(dir, "npm", "node_modules", ".bin")].filter(Boolean).join(path.delimiter),
|
|
1014
|
+
PYTHONPATH: [base.PYTHONPATH ?? "", path.join(dir, "py")].filter(Boolean).join(path.delimiter),
|
|
1015
|
+
NODE_PATH: [base.NODE_PATH ?? "", path.join(dir, "npm", "node_modules")].filter(Boolean).join(path.delimiter),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
@@ -6,6 +6,8 @@ export interface ScriptSpec {
|
|
|
6
6
|
* script inline. Exactly one of the two is the program. */
|
|
7
7
|
run: string;
|
|
8
8
|
description: string;
|
|
9
|
+
/** The tool.md body, for the model: how to call it and what it means. */
|
|
10
|
+
guide?: string;
|
|
9
11
|
args: Record<string, string>;
|
|
10
12
|
interpreter?: string;
|
|
11
13
|
/** Seconds this script may run — for the crawl that legitimately takes
|
|
@@ -45,6 +47,20 @@ export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<
|
|
|
45
47
|
code: number | null;
|
|
46
48
|
out: string;
|
|
47
49
|
}>;
|
|
50
|
+
/**
|
|
51
|
+
* A host path as the script's container sees it: under the agent's own
|
|
52
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
53
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
54
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
55
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
56
|
+
*/
|
|
57
|
+
export declare function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string;
|
|
58
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
59
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
60
|
+
export declare function stageWorkspaceArg(arg: string, workspaceRoot: string): {
|
|
61
|
+
host: string;
|
|
62
|
+
container: string;
|
|
63
|
+
} | null;
|
|
48
64
|
export interface ExecutionContext {
|
|
49
65
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
50
66
|
* directly inside the isolation the step already has. */
|
package/dist/src/script-tools.js
CHANGED
|
@@ -55,6 +55,7 @@ export function parseScripts(raw) {
|
|
|
55
55
|
run: run ?? "",
|
|
56
56
|
...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
|
|
57
57
|
description: typeof e.description === "string" ? e.description : "",
|
|
58
|
+
...(typeof e.guide === "string" && e.guide.trim() ? { guide: e.guide } : {}),
|
|
58
59
|
args,
|
|
59
60
|
interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
|
|
60
61
|
secrets: e.secrets === "proxied" ? "proxied" : undefined,
|
|
@@ -183,14 +184,18 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
183
184
|
// Container path: same command, executed inside an isolated container
|
|
184
185
|
// with only the agent's directory (plus declared shared dirs) mounted.
|
|
185
186
|
if (exec && exec.executor === "docker") {
|
|
187
|
+
// `workspace/…` is the workspace root on the host (the step's link)
|
|
188
|
+
// and in a run container; here there is no link, so each such
|
|
189
|
+
// argument is staged: copied in, pointed at, and copied back after.
|
|
190
|
+
const workspaceRoot = path.resolve(cwd, "..", "..");
|
|
191
|
+
const staged = [];
|
|
186
192
|
const inContainer = (p) => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return p;
|
|
193
|
+
const w = stageWorkspaceArg(p, workspaceRoot);
|
|
194
|
+
if (!w)
|
|
195
|
+
return containerPath(p, cwd, exec.mounts);
|
|
196
|
+
if (!staged.some((s) => s.host === w.host))
|
|
197
|
+
staged.push(w);
|
|
198
|
+
return w.container;
|
|
194
199
|
};
|
|
195
200
|
const argv = [
|
|
196
201
|
// The interpreter inside the image, not the host's venv path.
|
|
@@ -203,6 +208,7 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
203
208
|
runInContainer({
|
|
204
209
|
agentDir: path.resolve(cwd),
|
|
205
210
|
readOnly: exec.mounts,
|
|
211
|
+
staged,
|
|
206
212
|
image: exec.image,
|
|
207
213
|
argv,
|
|
208
214
|
env,
|
|
@@ -238,6 +244,41 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
238
244
|
}));
|
|
239
245
|
});
|
|
240
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* A host path as the script's container sees it: under the agent's own
|
|
249
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
250
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
251
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
252
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
253
|
+
*/
|
|
254
|
+
export function containerPath(p, agentDir, mounts) {
|
|
255
|
+
if (!path.isAbsolute(p))
|
|
256
|
+
return p;
|
|
257
|
+
const under = (root) => {
|
|
258
|
+
const rel = path.relative(root, p);
|
|
259
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
|
|
260
|
+
};
|
|
261
|
+
const own = under(path.resolve(agentDir));
|
|
262
|
+
if (own !== null)
|
|
263
|
+
return path.posix.join("/workspace", own.split(path.sep).join("/"));
|
|
264
|
+
const hit = Object.entries(mounts)
|
|
265
|
+
.map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
|
|
266
|
+
.filter((m) => m.rel !== null)
|
|
267
|
+
.sort((a, b) => b.len - a.len)[0];
|
|
268
|
+
return hit ? path.posix.join(hit.mount, hit.rel.split(path.sep).join("/")) : p;
|
|
269
|
+
}
|
|
270
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
271
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
272
|
+
export function stageWorkspaceArg(arg, workspaceRoot) {
|
|
273
|
+
if (arg !== "workspace" && !arg.startsWith("workspace/"))
|
|
274
|
+
return null;
|
|
275
|
+
const rel = arg.slice("workspace".length).replace(/^\/+/, "");
|
|
276
|
+
const host = path.resolve(workspaceRoot, rel);
|
|
277
|
+
const within = path.relative(workspaceRoot, host);
|
|
278
|
+
if (within.startsWith("..") || path.isAbsolute(within))
|
|
279
|
+
return null;
|
|
280
|
+
return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
|
|
281
|
+
}
|
|
241
282
|
// `run:` accepts:
|
|
242
283
|
// scripts/x.py this agent's own script
|
|
243
284
|
// workspace/tools/<t>/x.py a folder tool's own code, in this workspace
|
|
@@ -306,7 +347,8 @@ export function buildScriptTools(agentDir, scripts, env, libraryScripts = "", in
|
|
|
306
347
|
const tools = scripts.map((spec) => {
|
|
307
348
|
const shape = scriptArgShape(spec.args);
|
|
308
349
|
return tool(spec.name, `${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
|
|
309
|
-
`Credentials it needs are already in its environment
|
|
350
|
+
`Credentials it needs are already in its environment.` +
|
|
351
|
+
(spec.guide ? `\n\nHow to use it (from its tool.md):\n${spec.guide}` : ""), shape, async (args) => {
|
|
310
352
|
const started = Date.now();
|
|
311
353
|
const values = Object.fromEntries(Object.entries(args ?? {}).map(([k, v]) => [k, v === undefined ? "" : String(v)]));
|
|
312
354
|
const { code, out } = await runScript(agentDir, spec, values, env, libraryScripts, interpreters, exec);
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -123,6 +123,11 @@ export interface OAuth2Config {
|
|
|
123
123
|
/** Extra form fields some providers want (audience, scope, …). */
|
|
124
124
|
extra?: Record<string, string>;
|
|
125
125
|
}
|
|
126
|
+
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
127
|
+
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
128
|
+
export declare const OAUTH_REFRESH_TRIES = 3;
|
|
129
|
+
/** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
|
|
130
|
+
export declare function oauthRetryDelayMs(n: number): number;
|
|
126
131
|
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
|
|
127
132
|
export declare function isOAuth2Value(value: string): boolean;
|
|
128
133
|
/**
|
package/dist/src/secrets.js
CHANGED
|
@@ -402,6 +402,14 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
402
402
|
return { rewritten, unreadable };
|
|
403
403
|
}
|
|
404
404
|
const OAUTH2_PREFIX = "@oauth2 ";
|
|
405
|
+
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
406
|
+
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
407
|
+
export const OAUTH_REFRESH_TRIES = 3;
|
|
408
|
+
/** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
|
|
409
|
+
export function oauthRetryDelayMs(n) {
|
|
410
|
+
const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
|
|
411
|
+
return base * (n === 1 ? 1 : 3);
|
|
412
|
+
}
|
|
405
413
|
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
406
414
|
for (const field of ["token_url", "client_id", "client_secret"]) {
|
|
407
415
|
if (!config[field]?.trim())
|
|
@@ -440,12 +448,35 @@ async function exchange(config, cacheKey) {
|
|
|
440
448
|
client_secret: config.client_secret,
|
|
441
449
|
...(config.extra ?? {}),
|
|
442
450
|
});
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
451
|
+
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
452
|
+
// (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
|
|
453
|
+
// — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
|
|
454
|
+
// will say the same thing again, so it goes straight to the error below
|
|
455
|
+
// and the reconnect mail; a 5xx or 429 is the provider busy, retried.
|
|
456
|
+
let res;
|
|
457
|
+
let lastNetwork;
|
|
458
|
+
for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
|
|
459
|
+
if (attempt)
|
|
460
|
+
await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
|
|
461
|
+
try {
|
|
462
|
+
res = await fetchUntrusted(config.token_url, {
|
|
463
|
+
method: "POST",
|
|
464
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
465
|
+
body: body.toString(),
|
|
466
|
+
signal: AbortSignal.timeout(15_000),
|
|
467
|
+
}, { sameOrigin: true });
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
lastNetwork = err;
|
|
471
|
+
res = undefined;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (res.status === 429 || res.status >= 500)
|
|
475
|
+
continue;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
if (!res)
|
|
479
|
+
throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
|
|
449
480
|
const payload = (await res.json().catch(() => ({})));
|
|
450
481
|
if (!res.ok || !payload.access_token) {
|
|
451
482
|
throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
package/dist/src/starter.d.ts
CHANGED
|
@@ -28,3 +28,15 @@ export declare function accountFiles(account: string): StarterFile[];
|
|
|
28
28
|
*/
|
|
29
29
|
export declare function accountGitignore(): StarterFile;
|
|
30
30
|
export declare function starterFiles(workspace: string): StarterFile[];
|
|
31
|
+
/**
|
|
32
|
+
* A new workspace with nothing in it to delete: its AGENTS.md, the
|
|
33
|
+
* .gitignore that keeps its secret key out of git, and the CLAUDE.md a coding
|
|
34
|
+
* tool reads. No example agents or flow.
|
|
35
|
+
*
|
|
36
|
+
* The starter's researcher and writer were a demo, and a person making a
|
|
37
|
+
* workspace for real work met them as clutter — "why does a new workspace
|
|
38
|
+
* have agents?" (owner, 2026-10-05). The agents worth having are usually ones
|
|
39
|
+
* already written in another workspace, which `agents/import` copies in; the
|
|
40
|
+
* example is still one flag away (`foldrun new <name> --starter`).
|
|
41
|
+
*/
|
|
42
|
+
export declare function blankWorkspaceFiles(workspace: string): StarterFile[];
|