@foldrun/core 0.2.0 → 0.4.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 +31 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/deploy.js +4 -2
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +112 -2
- package/dist/src/platform.js +20 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +6 -5
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/deploy.ts +4 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/src/run-container.ts
CHANGED
|
@@ -25,6 +25,7 @@ import crypto from "node:crypto";
|
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
27
27
|
import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
|
|
28
|
+
import { divertedSummary, isDivertedPath } from "./test-mode.ts";
|
|
28
29
|
import type { EventExtra } from "./step-exec.ts";
|
|
29
30
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
30
31
|
import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
|
|
@@ -61,10 +62,14 @@ export interface ContainerStepInput {
|
|
|
61
62
|
/** The step's spend ceiling and the model's per-token price — see
|
|
62
63
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
63
64
|
budgetUsd?: number | null;
|
|
65
|
+
budgetNote?: string;
|
|
64
66
|
price?: { input: number; output: number } | null;
|
|
65
67
|
verify?: string;
|
|
66
68
|
/** `output: json` — checked inside, where the reply is. */
|
|
67
69
|
output?: "json";
|
|
70
|
+
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
71
|
+
schema?: Record<string, unknown> | boolean;
|
|
72
|
+
maxTurns?: number;
|
|
68
73
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
69
74
|
* container (/workspace/…, /library/…). */
|
|
70
75
|
search?: SearchRoot[];
|
|
@@ -106,6 +111,10 @@ export interface StepActuals {
|
|
|
106
111
|
export interface ContainerStepOutcome {
|
|
107
112
|
status: "completed" | "failed";
|
|
108
113
|
result: string | null;
|
|
114
|
+
/** Why the sandbox ended when the step did not: what the cluster said
|
|
115
|
+
* (OOMKilled, Evicted, the pod gone). Absent when the driver finished
|
|
116
|
+
* and said so itself. A retry policy reads this. */
|
|
117
|
+
reason?: string | null;
|
|
109
118
|
/** The model's final text block — see StepRecord.conclusion. Crosses the
|
|
110
119
|
* boundary with the rest of the outcome; the driver spreads it. */
|
|
111
120
|
conclusion?: string | null;
|
|
@@ -186,10 +195,70 @@ export function allowedBack(rel: string): boolean {
|
|
|
186
195
|
* the step did not touch it, so it is not ours to write back. Concurrent
|
|
187
196
|
* edits survive, and a step that genuinely changed a file still wins.
|
|
188
197
|
*/
|
|
198
|
+
/**
|
|
199
|
+
* What a workspace copy looked like when it went in: the sha256 of every
|
|
200
|
+
* file the step could hand back. Small enough to ride into the sandbox
|
|
201
|
+
* beside the input, so a driver that inherits the step can tell what the
|
|
202
|
+
* step changed without the staging copy the first driver held in /tmp.
|
|
203
|
+
*/
|
|
204
|
+
export function hashTree(dir: string): Record<string, string> {
|
|
205
|
+
const out: Record<string, string> = {};
|
|
206
|
+
const walk = (d: string) => {
|
|
207
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
208
|
+
const abs = path.join(d, entry.name);
|
|
209
|
+
const rel = path.relative(dir, abs).replaceAll("\\", "/");
|
|
210
|
+
if (entry.isSymbolicLink()) continue;
|
|
211
|
+
if (entry.isDirectory()) {
|
|
212
|
+
if (allowedBack(rel + "/")) walk(abs);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!allowedBack(rel)) continue;
|
|
216
|
+
out[rel] = crypto.createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
if (fs.existsSync(dir)) walk(dir);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Both sides changed a file the step was handed. When each is the handed-in
|
|
227
|
+
* content with lines added on the end — a ledger, history.md, a .jsonl — the
|
|
228
|
+
* answer is the base, then the host's additions, then the step's. Only the
|
|
229
|
+
* base's hash may be known, so the base is found as the longest shared
|
|
230
|
+
* leading run of whole lines whose hash matches. Anything else is null: a
|
|
231
|
+
* real conflict, which the caller must not settle by dropping one side.
|
|
232
|
+
*/
|
|
233
|
+
export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Buffer): Buffer | null {
|
|
234
|
+
// Both sides created the same new file: no shared base to append to, and
|
|
235
|
+
// joining two JSON documents is corruption, not a merge.
|
|
236
|
+
if (was === null) return null;
|
|
237
|
+
const h = host.toString("utf8");
|
|
238
|
+
const n = next.toString("utf8");
|
|
239
|
+
let common = 0;
|
|
240
|
+
const max = Math.min(h.length, n.length);
|
|
241
|
+
while (common < max && h.charCodeAt(common) === n.charCodeAt(common)) common++;
|
|
242
|
+
for (let end = common; end >= 0; end--) {
|
|
243
|
+
if (end > 0 && h[end - 1] !== "\n") continue;
|
|
244
|
+
const prefix = h.slice(0, end);
|
|
245
|
+
const matches =
|
|
246
|
+
typeof was === "string" ? sha(Buffer.from(prefix, "utf8")) === was : Buffer.from(prefix, "utf8").equals(was);
|
|
247
|
+
if (!matches) continue;
|
|
248
|
+
const hostAdded = h.slice(end);
|
|
249
|
+
const stepAdded = n.slice(end);
|
|
250
|
+
const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
|
|
251
|
+
return Buffer.from(prefix + joined + stepAdded, "utf8");
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
189
256
|
export function applyContainerChanges(
|
|
190
257
|
hostWs: string,
|
|
191
258
|
containerWs: string,
|
|
192
|
-
baseline?: string,
|
|
259
|
+
baseline?: string | Record<string, string>,
|
|
260
|
+
note?: (message: string) => void,
|
|
261
|
+
divert?: WriteDivert,
|
|
193
262
|
): string[] {
|
|
194
263
|
const applied: string[] = [];
|
|
195
264
|
const walk = (dir: string) => {
|
|
@@ -208,15 +277,60 @@ export function applyContainerChanges(
|
|
|
208
277
|
continue;
|
|
209
278
|
}
|
|
210
279
|
if (!allowedBack(rel)) continue;
|
|
211
|
-
|
|
280
|
+
let next: Buffer = fs.readFileSync(abs);
|
|
281
|
+
// What the step was handed: its bytes (a staging dir) or its hash.
|
|
282
|
+
// null when the file did not exist then; undefined when no baseline.
|
|
283
|
+
let was: Buffer | string | null | undefined;
|
|
284
|
+
if (typeof baseline === "string") {
|
|
285
|
+
const p = path.join(baseline, rel);
|
|
286
|
+
was = fs.existsSync(p) ? fs.readFileSync(p) : null;
|
|
287
|
+
} else if (baseline) {
|
|
288
|
+
was = baseline[rel.replaceAll("\\", "/")] ?? null;
|
|
289
|
+
}
|
|
290
|
+
const same = (b: Buffer) =>
|
|
291
|
+
was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
|
|
212
292
|
// Untouched by this step: whatever the host says now is more current
|
|
213
293
|
// than what we handed in, including a change made while it ran.
|
|
214
|
-
if (
|
|
215
|
-
const was = path.join(baseline, rel);
|
|
216
|
-
if (fs.existsSync(was) && fs.readFileSync(was).equals(next)) continue;
|
|
217
|
-
}
|
|
294
|
+
if (same(next)) continue;
|
|
218
295
|
const target = path.join(hostWs, rel);
|
|
219
|
-
|
|
296
|
+
const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
|
|
297
|
+
if (hostNow && hostNow.equals(next)) continue;
|
|
298
|
+
// The host moved too since the step was handed its copy — another run
|
|
299
|
+
// or a person wrote it. Writing the step's copy over it erased the
|
|
300
|
+
// other run's ledger rows on 2026-09-14. Merge appends; keep both
|
|
301
|
+
// sides of anything else.
|
|
302
|
+
const hostMoved = was !== undefined && hostNow !== null && !same(hostNow);
|
|
303
|
+
if (hostMoved) {
|
|
304
|
+
const merged = mergeAppends(was ?? null, hostNow, next);
|
|
305
|
+
if (merged) {
|
|
306
|
+
next = merged;
|
|
307
|
+
} else {
|
|
308
|
+
const ext = path.extname(rel);
|
|
309
|
+
const stem = rel.slice(0, rel.length - ext.length);
|
|
310
|
+
const conflictRel = `${stem}.conflict-${Date.now().toString(36)}${ext}`;
|
|
311
|
+
const conflictAbs = path.join(hostWs, conflictRel);
|
|
312
|
+
fs.mkdirSync(path.dirname(conflictAbs), { recursive: true });
|
|
313
|
+
fs.writeFileSync(conflictAbs, next);
|
|
314
|
+
applied.push(conflictRel);
|
|
315
|
+
note?.(
|
|
316
|
+
`write-back conflict on ${rel.replaceAll("\\", "/")}: it changed on the workspace while this step ran, ` +
|
|
317
|
+
`and not only by appending — kept the workspace's version; this step's version is ${conflictRel.replaceAll("\\", "/")}`,
|
|
318
|
+
);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// A test run's state/ and storage/ writes land under the run, not on
|
|
323
|
+
// the workspace: the step ran against the real files and its output
|
|
324
|
+
// is kept where a person can read it, but the next real run starts
|
|
325
|
+
// from state a test never touched. Decided after the merge so the
|
|
326
|
+
// diverted copy is what WOULD have been written, conflicts included.
|
|
327
|
+
if (divert && isDivertedPath(rel)) {
|
|
328
|
+
const aside = path.join(divert.to, rel);
|
|
329
|
+
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
|
330
|
+
fs.writeFileSync(aside, next);
|
|
331
|
+
divert.note(rel.replaceAll("\\", "/"), divertedSummary(rel.replaceAll("\\", "/"), hostNow, next));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
220
334
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
221
335
|
fs.writeFileSync(target, next);
|
|
222
336
|
applied.push(rel);
|
|
@@ -346,9 +460,9 @@ try {
|
|
|
346
460
|
|
|
347
461
|
// API and script tools, rebuilt in here from their specs. Secrets were
|
|
348
462
|
// substituted into API headers before the input crossed the boundary, so
|
|
349
|
-
// buildApiTools gets an already-resolved environment.
|
|
350
|
-
//
|
|
351
|
-
// the
|
|
463
|
+
// buildApiTools gets an already-resolved environment. verify: gets the
|
|
464
|
+
// container's own environment, whole: it is the boundary, assembled on
|
|
465
|
+
// the host from an allowlist, and the declared secrets are in it.
|
|
352
466
|
const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
|
|
353
467
|
const script = buildScriptTools(
|
|
354
468
|
agentDir,
|
|
@@ -393,10 +507,13 @@ try {
|
|
|
393
507
|
env,
|
|
394
508
|
timeoutSec: input.timeoutSec,
|
|
395
509
|
budgetUsd: input.budgetUsd,
|
|
510
|
+
budgetNote: input.budgetNote,
|
|
396
511
|
price: input.price,
|
|
397
512
|
verify: input.verify,
|
|
398
|
-
verifyEnv:
|
|
513
|
+
verifyEnv: env,
|
|
399
514
|
output: input.output,
|
|
515
|
+
schema: input.schema,
|
|
516
|
+
maxTurns: input.maxTurns,
|
|
400
517
|
// The container is the boundary; the SDK's own bash sandbox here would
|
|
401
518
|
// only block declared network use (SSH, curl) for no added safety.
|
|
402
519
|
sandboxBash: false,
|
|
@@ -504,8 +621,17 @@ export function runnerImageTag(): string {
|
|
|
504
621
|
* has not been built before. `npm pack` of the installed core, so the image
|
|
505
622
|
* runs the same bytes the server does.
|
|
506
623
|
*/
|
|
507
|
-
|
|
508
|
-
|
|
624
|
+
/**
|
|
625
|
+
* `platform`: build for another architecture — "linux/arm64" on the amd64
|
|
626
|
+
* box, through QEMU — and name the result with the arch appended so it never
|
|
627
|
+
* shadows the native image in the daemon. Only the publisher asks for this;
|
|
628
|
+
* a step always runs the native image. Needs binfmt registered on the host
|
|
629
|
+
* (tonistiigi/binfmt); the publisher does that itself, every time, because
|
|
630
|
+
* the registration does not survive a reboot.
|
|
631
|
+
*/
|
|
632
|
+
export function ensureRunnerImage(opts: { platform?: string } = {}): { tag: string; log: string[] } {
|
|
633
|
+
const arch = opts.platform ? `-${opts.platform.split("/").pop()}` : "";
|
|
634
|
+
const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag() + arch;
|
|
509
635
|
const log: string[] = [];
|
|
510
636
|
if (process.env.FOLDRUN_RUNNER_IMAGE) return { tag, log };
|
|
511
637
|
|
|
@@ -524,7 +650,8 @@ export function ensureRunnerImage(): { tag: string; log: string[] } {
|
|
|
524
650
|
fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
|
|
525
651
|
fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
|
|
526
652
|
log.push(`building runner image ${tag} (first run only)`);
|
|
527
|
-
const
|
|
653
|
+
const args = ["build", ...(opts.platform ? ["--platform", opts.platform] : []), "-t", tag, build];
|
|
654
|
+
const out = spawnSync(cli(), args, { encoding: "utf8" });
|
|
528
655
|
if (out.status !== 0) {
|
|
529
656
|
throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
|
|
530
657
|
}
|
|
@@ -611,6 +738,8 @@ function ensureRunNetwork(): string {
|
|
|
611
738
|
export interface RunInContainerArgs {
|
|
612
739
|
workspaceRoot: string;
|
|
613
740
|
libraryRoot: string;
|
|
741
|
+
/** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
|
|
742
|
+
galleryRoot?: string;
|
|
614
743
|
/** The account this step belongs to — the key for its dependency cache.
|
|
615
744
|
* Optional so an embedder that has no tenancy simply gets no cache. */
|
|
616
745
|
tenant?: string;
|
|
@@ -625,6 +754,75 @@ export interface RunInContainerArgs {
|
|
|
625
754
|
runId?: string;
|
|
626
755
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
627
756
|
size?: "small" | "large" | "heavy";
|
|
757
|
+
/** Re-attach to a sandbox a previous driver started, instead of creating
|
|
758
|
+
* one: its handle, and how many output lines that driver had applied.
|
|
759
|
+
* The executor replays nothing before `consumed`, and reads the step's
|
|
760
|
+
* files back from the sandbox's own copy-in snapshot. */
|
|
761
|
+
resume?: { ref: string; consumed: number } | null;
|
|
762
|
+
/** Called by an executor that can be resumed: when its sandbox exists
|
|
763
|
+
* (consumed 0), and before each output line it applies. The runner
|
|
764
|
+
* records these on the step, so the next driver knows where to attach. */
|
|
765
|
+
checkpoint?: (ref: string, consumed: number) => void;
|
|
766
|
+
/** A test run: where state/ and storage/ changes go instead of the
|
|
767
|
+
* workspace, and who to tell about each. See test-mode.ts. */
|
|
768
|
+
divert?: WriteDivert;
|
|
769
|
+
/** The run is a test run. An isolated executor labels the sandbox so the
|
|
770
|
+
* cluster can cut its network down to the egress proxy: a script tool
|
|
771
|
+
* calls the network directly, not through the proxy where the test-mode
|
|
772
|
+
* policy lives, so without this a test run's script could still send. */
|
|
773
|
+
test?: boolean;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** What a test run does with a write it must not apply. */
|
|
777
|
+
export interface WriteDivert {
|
|
778
|
+
/** The directory the diverted files are written under, by their
|
|
779
|
+
* workspace-relative path: runs/<id>/test-writes/. */
|
|
780
|
+
to: string;
|
|
781
|
+
note: (rel: string, summary: string) => void;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* What crosses into the container as its environment, and how.
|
|
786
|
+
*
|
|
787
|
+
* A docker --env-file is one `KEY=value` per line and cannot carry a
|
|
788
|
+
* newline. @file secrets are multi-line by nature (a PEM key, a cert), so
|
|
789
|
+
* they are staged as files NOW, into the copied-in workspace, and the env
|
|
790
|
+
* carries the container path — the one place a path is stable across the
|
|
791
|
+
* boundary. A plain secret whose value happens to hold a newline gets the
|
|
792
|
+
* same treatment, and the trace says so: it used to be dropped from the
|
|
793
|
+
* env file without a word, and the step failed later, elsewhere, on a
|
|
794
|
+
* variable that read as unset. Everything else goes through the env file.
|
|
795
|
+
*/
|
|
796
|
+
export function stageContainerEnv(
|
|
797
|
+
env: Record<string, string>,
|
|
798
|
+
wsIn: string,
|
|
799
|
+
agentRel: string,
|
|
800
|
+
emit: (type: "info" | "error", text: string) => void,
|
|
801
|
+
): Record<string, string> {
|
|
802
|
+
const containerEnv: Record<string, string> = {};
|
|
803
|
+
const stage = (k: string, content: string) => {
|
|
804
|
+
const rel = path.join(agentRel, ".secret-files", k.toLowerCase());
|
|
805
|
+
const abs = path.join(wsIn, rel);
|
|
806
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
|
|
807
|
+
fs.writeFileSync(abs, content, { mode: 0o600 });
|
|
808
|
+
return `/workspace/${rel.replaceAll("\\", "/")}`;
|
|
809
|
+
};
|
|
810
|
+
for (const [k, v] of Object.entries(env)) {
|
|
811
|
+
if (typeof v !== "string") continue;
|
|
812
|
+
if (isFileValue(v)) {
|
|
813
|
+
containerEnv[k] = stage(k, fileContent(v));
|
|
814
|
+
} else if (/[\r\n]/.test(v)) {
|
|
815
|
+
containerEnv[k] = stage(k, v);
|
|
816
|
+
emit(
|
|
817
|
+
"info",
|
|
818
|
+
`${k} holds a line break and cannot cross as an environment variable — ` +
|
|
819
|
+
`passed as a file instead; $${k} is its path in the sandbox`,
|
|
820
|
+
);
|
|
821
|
+
} else {
|
|
822
|
+
containerEnv[k] = v;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return containerEnv;
|
|
628
826
|
}
|
|
629
827
|
|
|
630
828
|
/** The limits a size class reserves. Large is the install's configured
|
|
@@ -718,6 +916,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
718
916
|
filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
|
|
719
917
|
});
|
|
720
918
|
const libIn = path.join(staging, "library");
|
|
919
|
+
if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
|
|
721
920
|
if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
|
|
722
921
|
else fs.mkdirSync(libIn, { recursive: true });
|
|
723
922
|
|
|
@@ -725,26 +924,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
725
924
|
fs.mkdirSync(jobIn);
|
|
726
925
|
fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
|
|
727
926
|
|
|
728
|
-
|
|
729
|
-
// --env-file cannot carry a newline. So they are staged as files *now*,
|
|
730
|
-
// into the copied-in workspace, and the env carries the container path —
|
|
731
|
-
// the one place a path is stable across the boundary. Everything else
|
|
732
|
-
// goes through the env file. (Without this the key silently vanished:
|
|
733
|
-
// the old filter dropped any value with a newline, and ssh got an empty
|
|
734
|
-
// -i path.)
|
|
735
|
-
const containerEnv: Record<string, string> = {};
|
|
736
|
-
for (const [k, v] of Object.entries(args.env)) {
|
|
737
|
-
if (typeof v !== "string") continue;
|
|
738
|
-
if (isFileValue(v)) {
|
|
739
|
-
const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
|
|
740
|
-
const abs = path.join(wsIn, rel);
|
|
741
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
|
|
742
|
-
fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
|
|
743
|
-
containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
|
|
744
|
-
} else if (!v.includes("\n")) {
|
|
745
|
-
containerEnv[k] = v;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
927
|
+
const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
|
|
748
928
|
|
|
749
929
|
const envFile = path.join(staging, "env");
|
|
750
930
|
fs.writeFileSync(
|
|
@@ -862,7 +1042,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
862
1042
|
fs.mkdirSync(wsOut);
|
|
863
1043
|
const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
|
|
864
1044
|
if (back.status === 0) {
|
|
865
|
-
applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
|
|
1045
|
+
applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
|
|
866
1046
|
} else {
|
|
867
1047
|
args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
|
|
868
1048
|
}
|