@kici-dev/agent 0.1.26 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bootstrap/ensure-init-runner.d.ts +23 -22
  2. package/dist/bootstrap/payload-source.d.ts +32 -0
  3. package/dist/bootstrap/probe-platform.d.ts +21 -0
  4. package/dist/bootstrap/restage-agent.d.ts +43 -0
  5. package/dist/bootstrap/run-restage.d.ts +12 -0
  6. package/dist/bootstrap/s3-payload-source.d.ts +35 -0
  7. package/dist/bootstrap/ssh-exec.d.ts +14 -0
  8. package/dist/bootstrap/stage-agent-payload.d.ts +41 -0
  9. package/dist/checkout/changed-files.d.ts +34 -0
  10. package/dist/config.d.ts +42 -14
  11. package/dist/container-ts-loader-hook.js +147710 -0
  12. package/dist/execution/artifacts/artifact-engine.d.ts +51 -0
  13. package/dist/execution/dep-installer.d.ts +3 -3
  14. package/dist/execution/init-runner.d.ts +4 -4
  15. package/dist/execution/job-runner.d.ts +41 -6
  16. package/dist/execution/log-streamer.d.ts +17 -2
  17. package/dist/execution/rule-evaluator.d.ts +1 -12
  18. package/dist/execution/sandbox/container-hardening.d.ts +80 -0
  19. package/dist/execution/sandbox/container-sandbox.d.ts +54 -0
  20. package/dist/execution/sandbox/container-ts-loader-hook.d.ts +26 -0
  21. package/dist/execution/sandbox/env-sanitizer.d.ts +12 -3
  22. package/dist/execution/sandbox/fork-runner.d.ts +14 -0
  23. package/dist/execution/sandbox/index.d.ts +2 -1
  24. package/dist/execution/sandbox/ipc-protocol.d.ts +82 -6
  25. package/dist/execution/sandbox/step-loop.d.ts +4 -0
  26. package/dist/execution/sandbox/types.d.ts +25 -4
  27. package/dist/execution/sandbox/workflow-runner.d.ts +111 -5
  28. package/dist/execution/streaming-zx-log.d.ts +38 -0
  29. package/dist/execution/tmp-gc.d.ts +22 -7
  30. package/dist/execution/workflow-loader.d.ts +32 -1
  31. package/dist/index.js +50 -45
  32. package/dist/provenance/statement-builder.d.ts +3 -2
  33. package/dist/server.d.ts +1 -1
  34. package/dist/server.js +1360 -193
  35. package/dist/workflow-runner-bundle.js +215393 -0
  36. package/dist/workflow-runner.js +937 -256
  37. package/dist/ws/orchestrator-client.d.ts +105 -1
  38. package/package.json +14 -12
  39. package/sbom.spdx.json +1092 -1723
@@ -1,16 +1,18 @@
1
- import { register } from "node:module";
1
+ import { createRequire, register } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import crypto$1, { createHash, randomUUID } from "node:crypto";
4
4
  import { existsSync } from "node:fs";
5
- import fsPromises, { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
6
- import os, { homedir, tmpdir } from "node:os";
5
+ import fsPromises, { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
6
+ import { homedir, tmpdir } from "node:os";
7
7
  import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { $ } from "zx";
9
9
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
10
- import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, StepConcurrencyKind, TimeoutReason } from "@kici-dev/engine";
11
- import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
10
+ import { createTempScope, makeTempDir } from "@kici-dev/core/tmp";
11
+ import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName } from "@kici-dev/engine";
12
+ import { execFile, execFileSync } from "node:child_process";
13
+ import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, isDynamicJobFn, isEventDefinition, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
12
14
  import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
13
- import { computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
15
+ import { HOME_ANCHOR, REPO_ANCHOR, computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
14
16
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
15
17
  import { IN_TOTO_PAYLOAD_TYPE, KICI_PROVENANCE_AUDIENCE, KICI_PROVENANCE_BUNDLE_MEDIA_TYPE } from "@kici-dev/engine/provenance/bundle";
16
18
  import { computeStatementHash } from "@kici-dev/engine/provenance/statement-hash";
@@ -25,7 +27,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { c, x } from "tar";
26
28
  import { runIdempotentStep } from "@kici-dev/core/idempotency";
27
29
  import { AsyncLocalStorage } from "node:async_hooks";
28
- import { execFile } from "node:child_process";
29
30
  import { promisify } from "node:util";
30
31
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
31
32
  import { parse, stringify } from "yaml";
@@ -48,11 +49,307 @@ var __exportAll = (all, no_symbols) => {
48
49
  return target;
49
50
  };
50
51
  //#endregion
52
+ //#region src/execution/streaming-zx-log.ts
53
+ /**
54
+ * Shared factory for the zx `log` callback that streams a subprocess's
55
+ * stdout/stderr into the captured/streamed run log, line by line, via `emit`.
56
+ *
57
+ * zx does NOT write child stdout/stderr to `process.stdout`; it pipes the
58
+ * child stdio to an internal VoidStream and surfaces each chunk through the
59
+ * shell's `log` callback as `{ kind: 'stdout' | 'stderr', data, verbose }`.
60
+ * The `verbose` flag is zx's per-invocation quiet/verbose decision:
61
+ *
62
+ * - stdout entries: `verbose = !piped && (snapshot.verbose && !snapshot.quiet)`
63
+ * - stderr entries: `verbose = !snapshot.quiet`
64
+ *
65
+ * so a step that opts into `$({ quiet: true })` (e.g. a `sops -d` decrypt of a
66
+ * credential) produces `verbose: false` entries. This factory HONORS that flag
67
+ * — it skips `verbose: false` entries — which is what makes `{ quiet: true }`
68
+ * actually suppress sensitive output from the run log. Without the gate, a
69
+ * decrypted-secret line leaks into the persisted/streamed log (zx's own default
70
+ * log function gates on the same flag: `if (!entry.verbose) return`).
71
+ *
72
+ * IMPORTANT: the shell that installs this callback MUST be constructed with
73
+ * `verbose: true`. With `verbose: true` zx flags ordinary (non-quiet)
74
+ * subprocess output `verbose: true` (captured) and a `{ quiet: true }` call
75
+ * `verbose: false` (suppressed). A `verbose: false` base would flag ordinary
76
+ * output `verbose: false` too, and this gate would then drop every line.
77
+ *
78
+ * The returned callback owns one line buffer PER STREAM, so partial chunks are
79
+ * coalesced into whole lines before `emit` is called. The buffers are separate
80
+ * because the two streams are independent pipes: a stdout chunk ending mid-line
81
+ * and a stderr chunk arriving next would otherwise concatenate into a single
82
+ * spliced line attributed to whichever kind completed it.
83
+ *
84
+ * `emit` receives the originating stream alongside the line so a diagnostic
85
+ * written to stderr stays distinguishable from ordinary progress output all the
86
+ * way to the persisted run log.
87
+ */
88
+ function makeStreamingZxLog(emit) {
89
+ const lineBufs = {
90
+ [LogStream.enum.stdout]: "",
91
+ [LogStream.enum.stderr]: ""
92
+ };
93
+ return (entry) => {
94
+ const e = entry;
95
+ if (e.kind !== "stdout" && e.kind !== "stderr") return;
96
+ if (!e.verbose) return;
97
+ const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
98
+ const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
99
+ const lines = (lineBufs[stream] + text).split("\n");
100
+ lineBufs[stream] = lines.pop();
101
+ for (const line of lines) if (line) emit(line, stream);
102
+ };
103
+ }
104
+ //#endregion
105
+ //#region src/checkout/ssh-auth.ts
106
+ /**
107
+ * Materialize an SSH private key (and optional pinned known_hosts) into a
108
+ * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
109
+ *
110
+ * Permissions:
111
+ * - private key mode 0o600 (required by OpenSSH — refuses to use world-
112
+ * readable keys).
113
+ * - known_hosts mode 0o600.
114
+ * - tempdir mode 0o700.
115
+ *
116
+ * SSH flags composed:
117
+ * - `-i <keyfile>` — identity file.
118
+ * - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
119
+ * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
120
+ * - host-key checking flags based on `hostKeyPolicy`.
121
+ */
122
+ async function setupSshAuth(opts) {
123
+ if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
124
+ const { path: tempDir, cleanup } = await makeTempDir("ssh");
125
+ const keyPath = join(tempDir, "id");
126
+ await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
127
+ const knownHostsPath = join(tempDir, "known_hosts");
128
+ await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
129
+ const parts = [
130
+ "ssh",
131
+ "-i",
132
+ escapeShellArg(keyPath),
133
+ "-o",
134
+ "IdentitiesOnly=yes",
135
+ "-o",
136
+ "BatchMode=yes",
137
+ "-o",
138
+ `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
139
+ ];
140
+ if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
141
+ else parts.push("-o", "StrictHostKeyChecking=accept-new");
142
+ return {
143
+ gitSshCommand: parts.join(" "),
144
+ tempDir,
145
+ cleanup
146
+ };
147
+ }
148
+ /**
149
+ * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
150
+ * wrapping so backslashes and spaces survive git's shell-parse of the
151
+ * command value.
152
+ */
153
+ function escapeShellArg(value) {
154
+ return `'${value.replace(/'/g, "'\\''")}'`;
155
+ }
156
+ //#endregion
157
+ //#region src/checkout/changed-files.ts
158
+ const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
159
+ const ZERO_SHA = /^0+$/;
160
+ const MAX_DEEPEN = 4;
161
+ const DEEPEN_STEP = 50;
162
+ const BASE_GIT_ARGS = [
163
+ "-c",
164
+ "safe.directory=*",
165
+ "-c",
166
+ "core.quotePath=false"
167
+ ];
168
+ /** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
169
+ async function buildAuthCtx(auth) {
170
+ if (!auth) return { args: [] };
171
+ if (auth.kind === "basic") {
172
+ const user = auth.user ?? "x-access-token";
173
+ return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
174
+ }
175
+ const sshSetup = await setupSshAuth({
176
+ privateKey: auth.secret,
177
+ hostKeyPolicy: auth.sshHostKeyPolicy,
178
+ knownHosts: auth.sshKnownHostsPem
179
+ });
180
+ return {
181
+ args: [],
182
+ env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
183
+ cleanup: () => sshSetup.cleanup()
184
+ };
185
+ }
186
+ function git(workDir, args, ctx) {
187
+ return execFileSync("git", [
188
+ ...ctx.args,
189
+ ...BASE_GIT_ARGS,
190
+ "-C",
191
+ workDir,
192
+ ...args
193
+ ], {
194
+ encoding: "utf8",
195
+ stdio: [
196
+ "ignore",
197
+ "pipe",
198
+ "pipe"
199
+ ],
200
+ ...ctx.env && { env: {
201
+ ...process.env,
202
+ ...ctx.env
203
+ } }
204
+ });
205
+ }
206
+ function tryGit(workDir, args, ctx) {
207
+ try {
208
+ git(workDir, args, ctx);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+ function parseNameOnly(out) {
215
+ return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
216
+ }
217
+ /** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
218
+ function ensureCommit(workDir, commitish, ctx) {
219
+ if (tryGit(workDir, [
220
+ "cat-file",
221
+ "-e",
222
+ `${commitish}^{commit}`
223
+ ], ctx)) return true;
224
+ if (tryGit(workDir, [
225
+ "fetch",
226
+ "--depth",
227
+ "1",
228
+ "origin",
229
+ commitish
230
+ ], ctx)) {
231
+ if (tryGit(workDir, [
232
+ "cat-file",
233
+ "-e",
234
+ `${commitish}^{commit}`
235
+ ], ctx)) return true;
236
+ }
237
+ for (let i = 0; i < MAX_DEEPEN; i++) {
238
+ if (!tryGit(workDir, [
239
+ "fetch",
240
+ `--deepen=${DEEPEN_STEP}`,
241
+ "origin"
242
+ ], ctx)) break;
243
+ if (tryGit(workDir, [
244
+ "cat-file",
245
+ "-e",
246
+ `${commitish}^{commit}`
247
+ ], ctx)) return true;
248
+ }
249
+ return false;
250
+ }
251
+ function pushDiff(workDir, before, ctx) {
252
+ const isZero = !before || ZERO_SHA.test(before);
253
+ const baseRef = isZero ? EMPTY_TREE_SHA : before;
254
+ if (!isZero && !ensureCommit(workDir, before, ctx)) return {
255
+ files: [],
256
+ status: "unavailable"
257
+ };
258
+ return {
259
+ files: parseNameOnly(git(workDir, [
260
+ "diff",
261
+ "--name-only",
262
+ baseRef,
263
+ "HEAD"
264
+ ], ctx)),
265
+ status: "fetched"
266
+ };
267
+ }
268
+ function prDiff(workDir, base, ctx) {
269
+ const candidates = [
270
+ base,
271
+ `origin/${base}`,
272
+ "FETCH_HEAD"
273
+ ];
274
+ const resolveBase = () => candidates.find((c) => tryGit(workDir, [
275
+ "rev-parse",
276
+ "--verify",
277
+ `${c}^{commit}`
278
+ ], ctx));
279
+ let baseRef = resolveBase();
280
+ if (!baseRef) {
281
+ if (!ensureCommit(workDir, base, ctx)) return {
282
+ files: [],
283
+ status: "unavailable"
284
+ };
285
+ baseRef = resolveBase();
286
+ }
287
+ if (!baseRef) return {
288
+ files: [],
289
+ status: "unavailable"
290
+ };
291
+ for (let i = 0; i <= MAX_DEEPEN; i++) {
292
+ if (tryGit(workDir, [
293
+ "merge-base",
294
+ baseRef,
295
+ "HEAD"
296
+ ], ctx)) return {
297
+ files: parseNameOnly(git(workDir, [
298
+ "diff",
299
+ "--name-only",
300
+ `${baseRef}...HEAD`
301
+ ], ctx)),
302
+ status: "fetched"
303
+ };
304
+ if (!tryGit(workDir, [
305
+ "fetch",
306
+ `--deepen=${DEEPEN_STEP}`,
307
+ "origin"
308
+ ], ctx)) break;
309
+ }
310
+ return {
311
+ files: [],
312
+ status: "unavailable"
313
+ };
314
+ }
315
+ /**
316
+ * Compute the changed-files list from the agent's local clone (HEAD is the
317
+ * checked-out head commit). Ground truth for job/step rule evaluation. `auth`
318
+ * (the same credentials used for the clone) authenticates the deepen / fetch
319
+ * calls so a private remote resolves. Returns `unavailable` for diff-less
320
+ * events (schedule/tag/manual) or any git failure — never throws.
321
+ */
322
+ async function computeChangedFiles(workDir, event, auth) {
323
+ let ctx;
324
+ try {
325
+ if (event.type !== "push" && event.type !== "pull_request") return {
326
+ files: [],
327
+ status: "unavailable"
328
+ };
329
+ ctx = await buildAuthCtx(auth);
330
+ if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
331
+ const base = event.baseBranch ?? event.targetBranch;
332
+ if (!base) return {
333
+ files: [],
334
+ status: "unavailable"
335
+ };
336
+ return prDiff(workDir, base, ctx);
337
+ } catch {
338
+ return {
339
+ files: [],
340
+ status: "unavailable"
341
+ };
342
+ } finally {
343
+ if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
344
+ }
345
+ }
346
+ //#endregion
51
347
  //#region src/provenance/statement-builder.ts
52
348
  /**
53
349
  * Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
54
350
  * token claims plus the caller-supplied subject. The build context comes
55
- * entirely from the JWT claims (Platform-minted, unforgeable), so the
351
+ * entirely from the JWT claims (minted server-side by the orchestrator,
352
+ * unforgeable), so the
56
353
  * statement's identity equals the token's identity by construction.
57
354
  */
58
355
  /**
@@ -384,7 +681,7 @@ async function excludeScratchFromGit(repoWorkDir) {
384
681
  const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
385
682
  await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
386
683
  } catch (err) {
387
- logger$4.warn("Failed to register scratch dir glob in .git/info/exclude", {
684
+ logger$5.warn("Failed to register scratch dir glob in .git/info/exclude", {
388
685
  excludePath,
389
686
  error: err instanceof Error ? err.message : String(err)
390
687
  });
@@ -445,7 +742,7 @@ async function cleanupScratch(scratchDir) {
445
742
  force: true
446
743
  });
447
744
  } catch (cleanupErr) {
448
- logger$4.warn("Scratch dir cleanup failed (orphan left behind)", {
745
+ logger$5.warn("Scratch dir cleanup failed (orphan left behind)", {
449
746
  scratchDir,
450
747
  error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
451
748
  });
@@ -470,7 +767,7 @@ async function cleanupScratch(scratchDir) {
470
767
  */
471
768
  async function restoreDeps(workDir, depsUrl, depsHash) {
472
769
  depsUrl = resolveOrchestratorUrl(depsUrl);
473
- logger$4.info("Downloading dependency tarball", { url: depsUrl });
770
+ logger$5.info("Downloading dependency tarball", { url: depsUrl });
474
771
  const kiciDir = join(workDir, ".kici");
475
772
  if (depsUrl.startsWith("file://")) {
476
773
  const localPath = fileURLToPath(depsUrl);
@@ -484,7 +781,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
484
781
  await moveScratchIntoRepo(scratchDir, workDir);
485
782
  await cleanupScratch(scratchDir);
486
783
  const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
487
- logger$4.info("Dependencies restored from cache (file)", {
784
+ logger$5.info("Dependencies restored from cache (file)", {
488
785
  sizeMB,
489
786
  targetDir: workDir
490
787
  });
@@ -493,7 +790,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
493
790
  if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
494
791
  let lastError;
495
792
  for (let attempt = 0; attempt <= 2; attempt++) {
496
- if (attempt > 0) logger$4.warn("Retrying dep tarball download", {
793
+ if (attempt > 0) logger$5.warn("Retrying dep tarball download", {
497
794
  attempt,
498
795
  url: depsUrl
499
796
  });
@@ -502,11 +799,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
502
799
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
503
800
  await moveScratchIntoRepo(scratchDir, workDir);
504
801
  await cleanupScratch(scratchDir);
505
- logger$4.info("Dependencies restored from cache (stream)", { targetDir: workDir });
802
+ logger$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
506
803
  return;
507
804
  } catch (err) {
508
805
  lastError = err instanceof Error ? err : new Error(String(err));
509
- logger$4.warn("Dep tarball download failed", {
806
+ logger$5.warn("Dep tarball download failed", {
510
807
  attempt,
511
808
  error: lastError.message
512
809
  });
@@ -514,9 +811,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
514
811
  }
515
812
  throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
516
813
  }
517
- var logger$4, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
814
+ var logger$5, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
518
815
  var init_dep_restore = __esmMin((() => {
519
- logger$4 = createLogger({ prefix: "dep-restore" });
816
+ logger$5 = createLogger({ prefix: "dep-restore" });
520
817
  DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
521
818
  SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
522
819
  SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
@@ -621,13 +918,9 @@ init_download();
621
918
  * into place so a partial restore never leaves half-written paths in the live
622
919
  * tree (mirrors dep-restore).
623
920
  */
624
- const logger$3 = createLogger({ prefix: "cache-engine" });
921
+ const logger$4 = createLogger({ prefix: "cache-engine" });
625
922
  /** Download timeout for a presigned cache GET: 5 minutes. */
626
923
  const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
627
- /** Anchor prefix for repo-root-relative cache entries inside the tar. */
628
- const REPO_ANCHOR = "__repo__";
629
- /** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
630
- const HOME_ANCHOR = "__home__";
631
924
  /**
632
925
  * Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
633
926
  * Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
@@ -668,7 +961,7 @@ function anchorEntries(workDir, paths, roots) {
668
961
  */
669
962
  async function packCachePaths(workDir, paths, roots) {
670
963
  const entries = anchorEntries(workDir, paths, roots);
671
- const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
964
+ const { path: staging, cleanup } = await makeTempDir("cache-pack");
672
965
  try {
673
966
  const topLevel = /* @__PURE__ */ new Set();
674
967
  for (const e of entries) {
@@ -689,7 +982,7 @@ async function packCachePaths(workDir, paths, roots) {
689
982
  for await (const chunk of stream) chunks.push(Buffer.from(chunk));
690
983
  const tarball = Buffer.concat(chunks);
691
984
  const hash = sha256(tarball);
692
- logger$3.info("packed user cache", {
985
+ logger$4.info("packed user cache", {
693
986
  sizeBytes: tarball.length,
694
987
  hash: hash.slice(0, 12),
695
988
  paths
@@ -699,10 +992,7 @@ async function packCachePaths(workDir, paths, roots) {
699
992
  hash
700
993
  };
701
994
  } finally {
702
- await rm(staging, {
703
- recursive: true,
704
- force: true
705
- });
995
+ await cleanup();
706
996
  }
707
997
  }
708
998
  /** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
@@ -738,17 +1028,14 @@ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
738
1028
  cb(null, chunk);
739
1029
  } });
740
1030
  await mkdir(workDir, { recursive: true });
741
- const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
1031
+ const { path: scratch, cleanup } = await makeTempDir("cache-extract");
742
1032
  try {
743
1033
  await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
744
1034
  const digest = hash.digest("hex");
745
1035
  if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
746
1036
  await moveAnchoredGroups(scratch, workDir, home);
747
1037
  } finally {
748
- await rm(scratch, {
749
- recursive: true,
750
- force: true
751
- });
1038
+ await cleanup();
752
1039
  }
753
1040
  }
754
1041
  /** Build the imperative `ctx.cache` API bound to a workDir + transport. */
@@ -758,7 +1045,7 @@ function createCacheApi(workDir, transport, roots) {
758
1045
  const r = await transport.restore(spec.key, spec.restoreKeys);
759
1046
  if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
760
1047
  await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
761
- logger$3.info("user cache restored", {
1048
+ logger$4.info("user cache restored", {
762
1049
  key: spec.key,
763
1050
  matchedKey: r.matchedKey
764
1051
  });
@@ -770,14 +1057,14 @@ function createCacheApi(workDir, transport, roots) {
770
1057
  async save(spec) {
771
1058
  const begin = await transport.beginSave(spec.key);
772
1059
  if (begin.skip || !begin.uploadUrl) {
773
- logger$3.info("user cache save skipped (key exists)", { key: spec.key });
1060
+ logger$4.info("user cache save skipped (key exists)", { key: spec.key });
774
1061
  return;
775
1062
  }
776
1063
  const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
777
1064
  const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
778
1065
  await uploadToPresignedUrl(begin.uploadUrl, tarball);
779
1066
  await transport.completeSave(spec.key, hash, tarball.length);
780
- logger$3.info("user cache saved", {
1067
+ logger$4.info("user cache saved", {
781
1068
  key: spec.key,
782
1069
  sizeBytes: tarball.length
783
1070
  });
@@ -902,6 +1189,98 @@ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
902
1189
  }
903
1190
  }
904
1191
  //#endregion
1192
+ //#region src/execution/artifacts/artifact-engine.ts
1193
+ /**
1194
+ * User-facing artifacts engine (sandbox-side).
1195
+ *
1196
+ * Packs `ctx.artifacts.upload(name, paths)` into a gzip tarball and uploads it
1197
+ * under a run-scoped, named key; `ctx.artifacts.download(name, destDir)` streams
1198
+ * the tarball back with on-the-fly SHA-256 verification and extracts it. Reuses
1199
+ * the cache engine's pack/extract/anchor primitives (`packCachePaths`,
1200
+ * `downloadAndExtractCache`) — the tar layout, path-safety, and multi-root
1201
+ * anchoring are identical; only the addressing (named + immutable-per-run
1202
+ * instead of content-keyed) and the transport differ.
1203
+ *
1204
+ * Drives the orchestrator over an injected request-response transport
1205
+ * (IPC -> agent WS -> orchestrator); the agent never holds bucket credentials
1206
+ * (presigned URLs only). A rejected upload (duplicate name, size cap, run cap,
1207
+ * org quota) throws with the orchestrator's reason verbatim; a missing artifact
1208
+ * on download throws a clear not-found error. When the refusal is not an
1209
+ * enforcement gate — artifacts are unconfigured, the run is unresolvable, or the
1210
+ * store errored — the orchestrator sends a safe free-text detail that both paths
1211
+ * surface as `artifact "<name>": <detail>`, so a misconfiguration is never
1212
+ * mistaken for a quota rejection or a missing artifact.
1213
+ *
1214
+ * A name that violates the artifact-name contract is refused here, before the
1215
+ * wire, and rendered in that same `artifact "<name>": <detail>` shape from the
1216
+ * shared engine builder — so the author reads one sentence whether the sandbox
1217
+ * or the orchestrator caught it.
1218
+ */
1219
+ const logger$3 = createLogger({ prefix: "artifact-engine" });
1220
+ /** Human-readable message for an upload rejection reason or internal-failure detail. */
1221
+ function rejectionMessage(name, reason, error) {
1222
+ switch (reason) {
1223
+ case "duplicate_name": return `artifact "${name}" already uploaded in this run (artifacts are immutable per run)`;
1224
+ case "size_cap": return `artifact "${name}" exceeds the per-artifact size cap`;
1225
+ case "run_cap": return `artifact "${name}" would exceed this run's artifact count cap`;
1226
+ case "org_quota": return `artifact "${name}" would exceed the organization's artifact storage quota`;
1227
+ default: return error ? `artifact "${name}": ${error}` : `artifact "${name}" upload was rejected`;
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Reject a name that violates the shared contract with the same sentence the
1232
+ * orchestrator would have produced.
1233
+ *
1234
+ * The `artifact "<name>": ` prefix is added here deliberately. On the
1235
+ * orchestrator path that prefix comes from {@link rejectionMessage}'s default
1236
+ * branch; the sandbox throws directly and bypasses it, so it has to supply the
1237
+ * prefix itself for the two paths to render one string.
1238
+ */
1239
+ function assertArtifactName(name) {
1240
+ const detail = checkArtifactName(name);
1241
+ if (detail) throw new Error(`artifact "${name}": ${artifactInvalidNameError(detail)}`);
1242
+ }
1243
+ /** Build the imperative `ctx.artifacts` API bound to a workDir + transport. */
1244
+ function createArtifactsApi(workDir, transport, roots) {
1245
+ return {
1246
+ async upload(name, paths) {
1247
+ assertArtifactName(name);
1248
+ if (paths.length === 0) throw new Error(`artifact "${name}" upload requires at least one path`);
1249
+ const { tarball, hash } = await packCachePaths(workDir, paths, roots);
1250
+ const grant = await transport.beginUpload(name, tarball.length);
1251
+ if (grant.outcome === "rejected" || !grant.uploadUrl || !grant.storageKey) throw new Error(rejectionMessage(name, grant.reason, grant.error));
1252
+ const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
1253
+ await uploadToPresignedUrl(grant.uploadUrl, tarball);
1254
+ await transport.completeUpload(name, tarball.length, hash, grant.storageKey);
1255
+ logger$3.info("artifact uploaded", {
1256
+ name,
1257
+ sizeBytes: tarball.length,
1258
+ sha256: hash.slice(0, 12)
1259
+ });
1260
+ return {
1261
+ size: tarball.length,
1262
+ sha256: hash
1263
+ };
1264
+ },
1265
+ async download(name, destDir) {
1266
+ assertArtifactName(name);
1267
+ const lookup = await transport.download(name);
1268
+ if (lookup.outcome === "not_found" || !lookup.downloadUrl || !lookup.sha256) throw new Error(lookup.error ? `artifact "${name}": ${lookup.error}` : `artifact "${name}" was not found in this run`);
1269
+ const dest = destDir ?? workDir;
1270
+ await downloadAndExtractCache(lookup.downloadUrl, dest, lookup.sha256, roots);
1271
+ logger$3.info("artifact downloaded", {
1272
+ name,
1273
+ destDir: dest,
1274
+ sizeBytes: lookup.sizeBytes
1275
+ });
1276
+ return {
1277
+ size: lookup.sizeBytes ?? 0,
1278
+ sha256: lookup.sha256
1279
+ };
1280
+ }
1281
+ };
1282
+ }
1283
+ //#endregion
905
1284
  //#region src/execution/sandbox/log-masker.ts
906
1285
  /**
907
1286
  * Secret value masking for log lines.
@@ -1060,7 +1439,7 @@ function parsePathFileContent(content) {
1060
1439
  }
1061
1440
  /** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
1062
1441
  async function createEnvFiles(baseDir) {
1063
- const dir = await mkdtemp(join(baseDir, "kici-env-"));
1442
+ const { path: dir } = await makeTempDir("env", { base: baseDir });
1064
1443
  const envFile = join(dir, "env");
1065
1444
  const pathFile = join(dir, "path");
1066
1445
  await writeFile(envFile, "");
@@ -1200,25 +1579,6 @@ async function executeHook(opts) {
1200
1579
  //#endregion
1201
1580
  //#region src/execution/rule-evaluator.ts
1202
1581
  initZx();
1203
- /**
1204
- * Create RuleContext for agent-side rule evaluation.
1205
- *
1206
- * @param event - Event payload from the dispatch message
1207
- * @param changedFiles - List of files changed in this event
1208
- * @param env - Merged environment variables
1209
- * @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
1210
- * @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
1211
- */
1212
- function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs = {}, fanout) {
1213
- return {
1214
- event,
1215
- changedFiles,
1216
- env,
1217
- dispatchInputs,
1218
- ...fanout && { fanout },
1219
- $
1220
- };
1221
- }
1222
1582
  //#endregion
1223
1583
  //#region src/execution/sandbox/parallel-scheduler.ts
1224
1584
  /**
@@ -1440,6 +1800,58 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opt
1440
1800
  ...res.drift != null && { drift: res.drift }
1441
1801
  };
1442
1802
  }
1803
+ /** Most characters of a failing step's error message that reach the run log. */
1804
+ const STEP_FAILURE_LOG_MAX_CHARS = 8192;
1805
+ /**
1806
+ * Emit a failing step's error message into the step's own log stream.
1807
+ *
1808
+ * A step failure is otherwise reported only on `step.complete` (which persists
1809
+ * to the step row), so a reader of the run log sees a step go red with nothing
1810
+ * explaining why. Subprocess wrappers routinely pack the real diagnosis —
1811
+ * captured stderr, exit code, kill signal — into the thrown error's message, so
1812
+ * that message is the diagnosis and it belongs in the log.
1813
+ *
1814
+ * Tagged `stderr`: it is a failure, not progress. Bounded to
1815
+ * {@link STEP_FAILURE_LOG_MAX_LINES} lines / {@link STEP_FAILURE_LOG_MAX_CHARS}
1816
+ * characters, because a wrapped process error can carry a subprocess's entire
1817
+ * output; what is dropped is stated in the log rather than silently cut. The
1818
+ * full untruncated message still travels on `step.complete`.
1819
+ *
1820
+ * Goes through the same `sendFn` as every other log line, so the runner's
1821
+ * secret masking applies exactly as it does to the step's ordinary output.
1822
+ *
1823
+ * One consequence to know: a `$({ quiet: true })` command's output is kept out
1824
+ * of the log by the `verbose` gate in `streaming-zx-log.ts`, but when such a
1825
+ * command FAILS, zx packs its captured output into the thrown error's message —
1826
+ * which this function then writes to the log. That text is already persisted
1827
+ * unmasked on `step.complete` (the step row the dashboard renders), so the copy
1828
+ * written here is the more protected of the two, and surfacing it is the whole
1829
+ * point: a quiet command that fails is exactly the failure an operator cannot
1830
+ * otherwise diagnose. Registered secret values are masked; anything the masker
1831
+ * has never been told about is not.
1832
+ */
1833
+ function emitStepFailureLog(stepName, stepIndex, message, sendFn) {
1834
+ const prefix = `[kici] Step '${stepName}' failed: `;
1835
+ const kept = (prefix + (message.length > 8192 ? message.slice(0, STEP_FAILURE_LOG_MAX_CHARS) : message)).split("\n").slice(0, 100);
1836
+ for (const line of kept) if (line) sendFn({
1837
+ type: "log.line",
1838
+ stepIndex,
1839
+ line,
1840
+ stream: LogStream.enum.stderr
1841
+ });
1842
+ const emittedChars = Math.max(0, kept.join("\n").length - prefix.length);
1843
+ const omittedChars = message.length - emittedChars;
1844
+ if (omittedChars > 0) {
1845
+ let omittedLines = 0;
1846
+ for (let i = emittedChars; i < message.length; i++) if (message.charCodeAt(i) === 10) omittedLines++;
1847
+ sendFn({
1848
+ type: "log.line",
1849
+ stepIndex,
1850
+ line: `[kici] … error message truncated (${omittedLines} more line(s), ${omittedChars} more character(s)); see the step's recorded error for the full text`,
1851
+ stream: LogStream.enum.stderr
1852
+ });
1853
+ }
1854
+ }
1443
1855
  /**
1444
1856
  * Execute a single step with timeout enforcement.
1445
1857
  *
@@ -1530,6 +1942,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1530
1942
  }
1531
1943
  const exitCode = extractExitCode(e);
1532
1944
  const signal = extractSignal(e);
1945
+ emitStepFailureLog(step.name, stepIndex, error.message, sendFn);
1533
1946
  const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1534
1947
  emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1535
1948
  sendFn({
@@ -1580,14 +1993,60 @@ function extractSignal(error) {
1580
1993
  if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
1581
1994
  }
1582
1995
  /**
1583
- * Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
1584
- * fails; returns null when the step should run normally.
1996
+ * Evaluate step-level rules. Returns null when the step should run normally.
1997
+ * Otherwise returns a terminal `StepIterationOutcome`:
1998
+ *
1999
+ * - a rule's `check()` **threw** (`evaluationError`) → FAIL the step (`failed`
2000
+ * status + error surfaced, `shouldBreak: true`): the gate could not be
2001
+ * evaluated, so silently skipping would be a false green.
2002
+ * - a rule cleanly returned `false` → clean skip (`skipped` status,
2003
+ * `shouldBreak: false`), the loop continues to the next step (unchanged).
1585
2004
  */
1586
2005
  async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1587
2006
  if (!step.rules || step.rules.length === 0) return null;
1588
- const ruleCtx = createRuleContext(opts.event, [], opts.env, opts.dispatchInputs ?? {}, opts.fanout);
2007
+ const ev = opts.event;
2008
+ const ruleCtx = createRuleContext({
2009
+ event: opts.event,
2010
+ changedFiles: ev.changedFiles,
2011
+ changedFilesStatus: ev.changedFilesStatus,
2012
+ env: opts.env,
2013
+ dispatchInputs: opts.dispatchInputs ?? {},
2014
+ fanout: opts.fanout
2015
+ });
1589
2016
  const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
1590
2017
  if (ruleResult.allPassed) return null;
2018
+ if (ruleResult.evaluationError) {
2019
+ const message = `rule '${ruleResult.evaluationError.label}' errored: ${ruleResult.evaluationError.message}`;
2020
+ opts.sendIpc({
2021
+ type: "step.start",
2022
+ stepIndex,
2023
+ stepName: step.name
2024
+ });
2025
+ opts.sendIpc({
2026
+ type: "step.complete",
2027
+ stepIndex,
2028
+ status: ExecutionStepStatus.enum.failed,
2029
+ durationMs: 0,
2030
+ error: { message }
2031
+ });
2032
+ opts.sendIpc({
2033
+ type: "log.line",
2034
+ stepIndex,
2035
+ line: `[kici] Step '${step.name}' failed: ${message}`,
2036
+ stream: LogStream.enum.stderr
2037
+ });
2038
+ return {
2039
+ result: {
2040
+ name: step.name,
2041
+ stepIndex,
2042
+ status: ExecutionStepStatus.enum.failed,
2043
+ durationMs: 0,
2044
+ error: { message }
2045
+ },
2046
+ shouldBreak: true,
2047
+ failedStepName: step.name
2048
+ };
2049
+ }
1591
2050
  opts.sendIpc({
1592
2051
  type: "step.start",
1593
2052
  stepIndex,
@@ -1605,10 +2064,13 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1605
2064
  line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
1606
2065
  });
1607
2066
  return {
1608
- name: step.name,
1609
- stepIndex,
1610
- status: ExecutionStepStatus.enum.skipped,
1611
- durationMs: 0
2067
+ result: {
2068
+ name: step.name,
2069
+ stepIndex,
2070
+ status: ExecutionStepStatus.enum.skipped,
2071
+ durationMs: 0
2072
+ },
2073
+ shouldBreak: false
1612
2074
  };
1613
2075
  }
1614
2076
  /**
@@ -1658,7 +2120,8 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
1658
2120
  opts.sendIpc({
1659
2121
  type: "log.line",
1660
2122
  stepIndex,
1661
- line: `[kici] Step '${step.name}' ${why}.`
2123
+ line: `[kici] Step '${step.name}' ${why}.`,
2124
+ stream: LogStream.enum.stderr
1662
2125
  });
1663
2126
  await opts.disposeStepResources?.(stepIndex);
1664
2127
  return {
@@ -1699,7 +2162,8 @@ async function runObserverHook(args) {
1699
2162
  if (!hookResult.success) opts.sendIpc({
1700
2163
  type: "log.line",
1701
2164
  stepIndex,
1702
- line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
2165
+ line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`,
2166
+ stream: LogStream.enum.stderr
1703
2167
  });
1704
2168
  }
1705
2169
  /**
@@ -1739,20 +2203,18 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
1739
2203
  opts.sendIpc({
1740
2204
  type: "log.line",
1741
2205
  stepIndex,
1742
- line: `[kici] Step '${step.name}' attempt ${n}/${max} failed: ${err.message}; retrying in ${delay}ms`
2206
+ line: `[kici] Step '${step.name}' attempt ${n}/${max} failed: ${err.message}; retrying in ${delay}ms`,
2207
+ stream: LogStream.enum.stderr
1743
2208
  });
1744
2209
  await new Promise((r) => setTimeout(r, delay));
1745
2210
  }
1746
2211
  return result;
1747
2212
  }
1748
2213
  async function runStepIteration(step, stepIndex, opts) {
1749
- const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1750
- if (skippedResult) {
2214
+ const ruleOutcome = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
2215
+ if (ruleOutcome) {
1751
2216
  await opts.disposeStepResources?.(stepIndex);
1752
- return {
1753
- result: skippedResult,
1754
- shouldBreak: false
1755
- };
2217
+ return ruleOutcome;
1756
2218
  }
1757
2219
  const gate = await maybeGateStepApproval(step, stepIndex, opts);
1758
2220
  if (gate) return gate;
@@ -2385,63 +2847,6 @@ function armJobDeadline(timeoutMs, onTimeout) {
2385
2847
  return { clear: () => clearTimeout(timer) };
2386
2848
  }
2387
2849
  //#endregion
2388
- //#region src/checkout/ssh-auth.ts
2389
- /**
2390
- * Materialize an SSH private key (and optional pinned known_hosts) into a
2391
- * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
2392
- *
2393
- * Permissions:
2394
- * - private key mode 0o600 (required by OpenSSH — refuses to use world-
2395
- * readable keys).
2396
- * - known_hosts mode 0o600.
2397
- * - tempdir mode 0o700.
2398
- *
2399
- * SSH flags composed:
2400
- * - `-i <keyfile>` — identity file.
2401
- * - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
2402
- * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
2403
- * - host-key checking flags based on `hostKeyPolicy`.
2404
- */
2405
- async function setupSshAuth(opts) {
2406
- if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
2407
- const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
2408
- const keyPath = join(tempDir, "id");
2409
- await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
2410
- const knownHostsPath = join(tempDir, "known_hosts");
2411
- await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
2412
- const parts = [
2413
- "ssh",
2414
- "-i",
2415
- escapeShellArg(keyPath),
2416
- "-o",
2417
- "IdentitiesOnly=yes",
2418
- "-o",
2419
- "BatchMode=yes",
2420
- "-o",
2421
- `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
2422
- ];
2423
- if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
2424
- else parts.push("-o", "StrictHostKeyChecking=accept-new");
2425
- return {
2426
- gitSshCommand: parts.join(" "),
2427
- tempDir,
2428
- async cleanup() {
2429
- await rm(tempDir, {
2430
- recursive: true,
2431
- force: true
2432
- });
2433
- }
2434
- };
2435
- }
2436
- /**
2437
- * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
2438
- * wrapping so backslashes and spaces survive git's shell-parse of the
2439
- * command value.
2440
- */
2441
- function escapeShellArg(value) {
2442
- return `'${value.replace(/'/g, "'\\''")}'`;
2443
- }
2444
- //#endregion
2445
2850
  //#region src/checkout/git-clone.ts
2446
2851
  /**
2447
2852
  * Strip auth credentials from git error messages to prevent token leakage.
@@ -2481,19 +2886,16 @@ async function gitClone(options) {
2481
2886
  let needsCustomEnv = false;
2482
2887
  let safeDirCleanup;
2483
2888
  if (repoUrl.startsWith("file://")) {
2484
- const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
2485
- const { tmpdir } = await import("node:os");
2889
+ const { writeFile } = await import("node:fs/promises");
2486
2890
  const path = await import("node:path");
2487
- const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
2891
+ const { makeTempDir } = await import("@kici-dev/core/tmp");
2892
+ const { path: dir, cleanup } = await makeTempDir("gitcfg");
2488
2893
  const cfgPath = path.join(dir, "config");
2489
2894
  await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
2490
2895
  envEntries.GIT_CONFIG_GLOBAL = cfgPath;
2491
2896
  needsCustomEnv = true;
2492
2897
  safeDirCleanup = async () => {
2493
- await rm(dir, {
2494
- recursive: true,
2495
- force: true
2496
- }).catch(() => {});
2898
+ await cleanup().catch(() => {});
2497
2899
  };
2498
2900
  }
2499
2901
  let sshSetup;
@@ -2788,7 +3190,7 @@ async function applyYarnrcBerryConfig(args) {
2788
3190
  const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
2789
3191
  const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
2790
3192
  const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
2791
- const cacheFolder = await mkdtemp(join(tmpdir(), "kici-yarn-berry-cache-"));
3193
+ const { path: cacheFolder, cleanup: cleanupCache } = await makeTempDir("yarn-berry-cache");
2792
3194
  const merged = {
2793
3195
  ...doc,
2794
3196
  nodeLinker: "node-modules",
@@ -2825,10 +3227,7 @@ async function applyYarnrcBerryConfig(args) {
2825
3227
  if (original === null) await unlink(yarnrcPath).catch(() => {});
2826
3228
  else await writeFile(yarnrcPath, original, { encoding: "utf8" });
2827
3229
  } catch {}
2828
- await rm(cacheFolder, {
2829
- recursive: true,
2830
- force: true
2831
- }).catch(() => {});
3230
+ await cleanupCache().catch(() => {});
2832
3231
  };
2833
3232
  return {
2834
3233
  extraEnv: {
@@ -3143,9 +3542,9 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
3143
3542
  * Install `.kici/` dependencies inline with the repo's package manager.
3144
3543
  *
3145
3544
  * Falls back to this when the dep cache is unavailable or a download fails.
3146
- * The install runs with an isolated cache/store directory (created in
3147
- * `os.tmpdir()`) to prevent cache poisoning between build jobs; the directory
3148
- * is removed after installation.
3545
+ * The install runs with an isolated cache/store directory (allocated under the
3546
+ * global temp base, which honors `KICI_TMPDIR`) to prevent cache poisoning
3547
+ * between build jobs; the directory is removed after installation.
3149
3548
  *
3150
3549
  * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
3151
3550
  * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
@@ -3234,7 +3633,7 @@ function envWithNodeOnPath(extraEnv, nodeDir) {
3234
3633
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
3235
3634
  async function runNpmInstall(args) {
3236
3635
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
3237
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
3636
+ const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
3238
3637
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3239
3638
  const buildArgs = (...prefix) => {
3240
3639
  const a = [
@@ -3259,10 +3658,7 @@ async function runNpmInstall(args) {
3259
3658
  maxBuffer: INSTALL_MAX_BUFFER
3260
3659
  });
3261
3660
  } finally {
3262
- await rm(cacheDir, {
3263
- recursive: true,
3264
- force: true
3265
- }).catch(() => {});
3661
+ await cleanup().catch(() => {});
3266
3662
  }
3267
3663
  }
3268
3664
  /**
@@ -3276,7 +3672,7 @@ async function runNpmInstall(args) {
3276
3672
  async function runPnpmInstall(args) {
3277
3673
  await assertPnpmAvailable();
3278
3674
  const { nodeDir } = resolveNpm();
3279
- const storeDir = await mkdtemp(join(tmpdir(), "kici-pnpm-store-"));
3675
+ const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
3280
3676
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3281
3677
  const argv = [
3282
3678
  "install",
@@ -3296,10 +3692,7 @@ async function runPnpmInstall(args) {
3296
3692
  maxBuffer: INSTALL_MAX_BUFFER
3297
3693
  });
3298
3694
  } finally {
3299
- await rm(storeDir, {
3300
- recursive: true,
3301
- force: true
3302
- }).catch(() => {});
3695
+ await cleanup().catch(() => {});
3303
3696
  }
3304
3697
  }
3305
3698
  /** Pure: argv for `yarn install` with an isolated cache folder. */
@@ -3325,7 +3718,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
3325
3718
  async function runYarnInstall(args) {
3326
3719
  await assertYarnAvailable();
3327
3720
  const { nodeDir } = resolveNpm();
3328
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-yarn-cache-"));
3721
+ const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
3329
3722
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3330
3723
  const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
3331
3724
  try {
@@ -3337,10 +3730,7 @@ async function runYarnInstall(args) {
3337
3730
  maxBuffer: INSTALL_MAX_BUFFER
3338
3731
  });
3339
3732
  } finally {
3340
- await rm(cacheDir, {
3341
- recursive: true,
3342
- force: true
3343
- }).catch(() => {});
3733
+ await cleanup().catch(() => {});
3344
3734
  }
3345
3735
  }
3346
3736
  /** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
@@ -3493,19 +3883,65 @@ function logSubprocessStreams(e, tokens) {
3493
3883
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3494
3884
  * Node's normal ESM lookup against `.kici/node_modules/`.
3495
3885
  */
3496
- const AGENT_SDK_VERSION = "0.1.26";
3497
- const AGENT_SDK_BUNDLE_HASH = "e06bd1acf349ba365bdb357f3d865e0cbb4ad4604a0270b707a681a4f440954e";
3498
3886
  /**
3499
- * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3500
- * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
3501
- * fly. Idempotent at our level via the `hookRegistered` flag; Node also
3502
- * tolerates repeated `register()` calls by stacking layers, but we avoid the
3503
- * noise.
3887
+ * Resolve the `@kici-dev/sdk` instance the workflow module itself imports.
3888
+ *
3889
+ * The workflow's `.result` proxies read the module-global step-outputs map of
3890
+ * whichever SDK copy the workflow file resolves which is generally a
3891
+ * different physical module than the agent's bundled SDK (the workflow is
3892
+ * imported from the cloned source tree and resolves its deps against that
3893
+ * tree's `node_modules`). Resolving via `createRequire(workflowFilePath)` walks
3894
+ * `node_modules` from the workflow file exactly the way the workflow's own
3895
+ * `import '@kici-dev/sdk'` does — including any hoisted copy — so the returned
3896
+ * setters mutate the SAME module-global map object the proxies read. Node caches
3897
+ * ESM modules by resolved URL, so importing that path yields the workflow's live
3898
+ * singleton, not a fresh copy.
3899
+ *
3900
+ * Falls back to the agent's bundled setters when resolution fails (mirrors
3901
+ * `resolveSdkSetters` in the compiler's test runner).
3902
+ */
3903
+ async function resolveWorkflowSdkSetters(workflowFilePath) {
3904
+ try {
3905
+ const sdk = await import(pathToFileURL(createRequire(workflowFilePath).resolve("@kici-dev/sdk")).href);
3906
+ if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
3907
+ setStepOutputsMap: sdk.setStepOutputsMap,
3908
+ setStepRefMap: sdk.setStepRefMap,
3909
+ setJobOutputsMap: sdk.setJobOutputsMap
3910
+ };
3911
+ } catch {}
3912
+ return {
3913
+ setStepOutputsMap,
3914
+ setStepRefMap,
3915
+ setJobOutputsMap
3916
+ };
3917
+ }
3918
+ const AGENT_SDK_VERSION = "0.2.0";
3919
+ const AGENT_SDK_BUNDLE_HASH = "fcd0a836aa1cb92debd93e0254450614d123b3748511aa4148d956fd72a7c29f";
3920
+ /**
3921
+ * Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
3922
+ * for subsequent dynamic `import()` calls. Idempotent at our level via the
3923
+ * `hookRegistered` flag; Node also tolerates repeated `register()` calls by
3924
+ * stacking layers, but we avoid the noise.
3925
+ *
3926
+ * Two registration branches:
3927
+ *
3928
+ * - **Container path** — when `KICI_TS_LOADER_HOOK_PATH` is set (only
3929
+ * `ContainerSandbox` sets it, pointing at a pure-JS hook bundle mounted into
3930
+ * the job container), register that on-disk hook by absolute `file://` URL.
3931
+ * `module.register` resolves the specifier on disk in a worker thread, so a
3932
+ * bare package specifier would need a `node_modules` tree next to the runner
3933
+ * bundle — which does not exist in a bare customer container. An absolute
3934
+ * `file://` URL sidesteps resolution entirely.
3935
+ * - **Fork / firecracker path** — when the env var is unset, register the
3936
+ * `@kici-dev/core/ts-loader-hook` oxc-transform hook by bare specifier,
3937
+ * resolved via the workspace `node_modules` those sandboxes bind read-only.
3504
3938
  */
3505
3939
  let hookRegistered = false;
3506
3940
  function ensureLoaderHookRegistered() {
3507
3941
  if (hookRegistered) return;
3508
- register("@kici-dev/core/ts-loader-hook", import.meta.url);
3942
+ const hookPath = process.env.KICI_TS_LOADER_HOOK_PATH;
3943
+ if (hookPath) register(pathToFileURL(hookPath).href, import.meta.url);
3944
+ else register("@kici-dev/core/ts-loader-hook", import.meta.url);
3509
3945
  hookRegistered = true;
3510
3946
  }
3511
3947
  /**
@@ -3560,7 +3996,10 @@ async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, reso
3560
3996
  const actualHash = computeContentHash(rawSource, assetDigest);
3561
3997
  if (actualHash !== expectedContentHash) throw new Error(`Lock file is out of date: workflow source changed without regenerating kici.lock.json (expected contentHash ${expectedContentHash}, got ${actualHash}, agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}). Run 'kici compile' and commit the updated lock file.`);
3562
3998
  }
3563
- return { module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`) };
3999
+ return {
4000
+ module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
4001
+ sdkSetters: await resolveWorkflowSdkSetters(filePath)
4002
+ };
3564
4003
  }
3565
4004
  /**
3566
4005
  * Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
@@ -3757,7 +4196,7 @@ function decryptBuffer(encrypted, aesKey) {
3757
4196
  */
3758
4197
  async function applyOverlay(config) {
3759
4198
  const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
3760
- const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "kici-overlay-"));
4199
+ const { path: tmpDir, cleanup } = await makeTempDir("overlay");
3761
4200
  try {
3762
4201
  logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
3763
4202
  let encryptedData;
@@ -3831,10 +4270,7 @@ async function applyOverlay(config) {
3831
4270
  verified: true
3832
4271
  };
3833
4272
  } finally {
3834
- await fsPromises.rm(tmpDir, {
3835
- recursive: true,
3836
- force: true
3837
- }).catch(() => {});
4273
+ await cleanup().catch(() => {});
3838
4274
  }
3839
4275
  }
3840
4276
  //#endregion
@@ -3856,7 +4292,7 @@ async function applyOverlay(config) {
3856
4292
  */
3857
4293
  init_download();
3858
4294
  init_dep_restore();
3859
- const AGENT_VERSION = "0.1.26";
4295
+ const AGENT_VERSION = "0.2.0";
3860
4296
  process.on("uncaughtException", (err) => {
3861
4297
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3862
4298
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3943,7 +4379,8 @@ function installOutputCapture() {
3943
4379
  for (const line of lines) if (line) captureSendFn({
3944
4380
  type: "log.line",
3945
4381
  stepIndex: stepIdx,
3946
- line
4382
+ line,
4383
+ stream: LogStream.enum.stderr
3947
4384
  });
3948
4385
  }
3949
4386
  return origStderrWrite(chunk, encodingOrCb, cb);
@@ -4196,6 +4633,33 @@ function waitForProvenanceResponse(requestId) {
4196
4633
  });
4197
4634
  });
4198
4635
  }
4636
+ /**
4637
+ * Pending promises for artifacts.response messages from the agent.
4638
+ * Key: requestId (correlates artifacts.request -> artifacts.response).
4639
+ */
4640
+ const pendingArtifactResponses = /* @__PURE__ */ new Map();
4641
+ /** Wait for an artifacts.response from the agent with the given requestId. */
4642
+ function waitForArtifactResponse(requestId) {
4643
+ return new Promise((resolve, reject) => {
4644
+ const timer = setTimeout(() => {
4645
+ pendingArtifactResponses.delete(requestId);
4646
+ reject(/* @__PURE__ */ new Error(`Artifact request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
4647
+ }, CACHE_RESPONSE_TIMEOUT_MS);
4648
+ pendingArtifactResponses.set(requestId, {
4649
+ resolve: (response) => {
4650
+ clearTimeout(timer);
4651
+ pendingArtifactResponses.delete(requestId);
4652
+ resolve(response);
4653
+ },
4654
+ reject: (err) => {
4655
+ clearTimeout(timer);
4656
+ pendingArtifactResponses.delete(requestId);
4657
+ reject(err);
4658
+ },
4659
+ timer
4660
+ });
4661
+ });
4662
+ }
4199
4663
  /** Wait for a cache.response from the agent with the given requestId. */
4200
4664
  function waitForCacheResponse(requestId) {
4201
4665
  return new Promise((resolve, reject) => {
@@ -4350,6 +4814,68 @@ function buildCacheTransport() {
4350
4814
  }
4351
4815
  };
4352
4816
  }
4817
+ /**
4818
+ * Build the {@link ArtifactTransport} the sandbox-side artifacts engine uses to
4819
+ * reach the orchestrator. Each method sends an `artifacts.request` IPC (relayed
4820
+ * by the agent over the WS as an `artifacts.upload.*` / `artifacts.download.*`
4821
+ * message) and awaits the matching `artifacts.response`. Mirrors
4822
+ * {@link buildCacheTransport}.
4823
+ */
4824
+ function buildArtifactTransport() {
4825
+ return {
4826
+ async beginUpload(name, declaredSizeBytes) {
4827
+ const requestId = randomUUID();
4828
+ sendMessage({
4829
+ type: "artifacts.request",
4830
+ requestId,
4831
+ op: "beginUpload",
4832
+ name,
4833
+ declaredSizeBytes
4834
+ });
4835
+ const response = await waitForArtifactResponse(requestId);
4836
+ if (response.error) throw new Error(`Artifact upload failed: ${response.error}`);
4837
+ return {
4838
+ outcome: response.uploadOutcome ?? "rejected",
4839
+ ...response.uploadUrl && { uploadUrl: response.uploadUrl },
4840
+ ...response.storageKey && { storageKey: response.storageKey },
4841
+ ...response.reason && { reason: response.reason },
4842
+ ...response.rejectionDetail && { error: response.rejectionDetail }
4843
+ };
4844
+ },
4845
+ async completeUpload(name, sizeBytes, sha256, storageKey) {
4846
+ const requestId = randomUUID();
4847
+ sendMessage({
4848
+ type: "artifacts.request",
4849
+ requestId,
4850
+ op: "completeUpload",
4851
+ name,
4852
+ sizeBytes,
4853
+ sha256,
4854
+ storageKey
4855
+ });
4856
+ const response = await waitForArtifactResponse(requestId);
4857
+ if (response.error) throw new Error(`Artifact upload-complete failed: ${response.error}`);
4858
+ },
4859
+ async download(name) {
4860
+ const requestId = randomUUID();
4861
+ sendMessage({
4862
+ type: "artifacts.request",
4863
+ requestId,
4864
+ op: "download",
4865
+ name
4866
+ });
4867
+ const response = await waitForArtifactResponse(requestId);
4868
+ if (response.error) throw new Error(`Artifact download failed: ${response.error}`);
4869
+ return {
4870
+ outcome: response.downloadOutcome ?? "not_found",
4871
+ ...response.downloadUrl && { downloadUrl: response.downloadUrl },
4872
+ ...response.sizeBytes !== void 0 && { sizeBytes: response.sizeBytes },
4873
+ ...response.sha256 && { sha256: response.sha256 },
4874
+ ...response.rejectionDetail && { error: response.rejectionDetail }
4875
+ };
4876
+ }
4877
+ };
4878
+ }
4353
4879
  /** Send a `provenance.request` IPC and await the matching `provenance.response`. */
4354
4880
  async function relayProvenanceIpc(request) {
4355
4881
  const requestId = randomUUID();
@@ -4505,6 +5031,9 @@ function dispatchAgentMessage(msg) {
4505
5031
  } else if (msg.type === "provenance.response") {
4506
5032
  const pending = pendingProvenanceResponses.get(msg.requestId);
4507
5033
  if (pending) pending.resolve(msg);
5034
+ } else if (msg.type === "artifacts.response") {
5035
+ const pending = pendingArtifactResponses.get(msg.requestId);
5036
+ if (pending) pending.resolve(msg);
4508
5037
  } else if (msg.type === "approval.resolved") {
4509
5038
  const pending = pendingApprovalResolutions.get(msg.requestId);
4510
5039
  if (pending) pending.resolve(msg);
@@ -4649,13 +5178,13 @@ function envelopeChildOutputs(envelope) {
4649
5178
  */
4650
5179
  function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
4651
5180
  const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
4652
- let stepTmpdir = null;
5181
+ let stepTmpHandle = null;
4653
5182
  const exposedEnvVars = /* @__PURE__ */ new Set();
4654
5183
  let mountCounter = 0;
4655
5184
  const env = process.env;
4656
5185
  async function ensureTmpdir() {
4657
- if (stepTmpdir === null) stepTmpdir = await fsPromises.mkdtemp(join(tmpdir(), "kici-secret-files-"));
4658
- return stepTmpdir;
5186
+ if (stepTmpHandle === null) stepTmpHandle = await makeTempDir("secret-files");
5187
+ return stepTmpHandle.path;
4659
5188
  }
4660
5189
  return createStepSecrets(mergedFlat, env, request.secretMeta, {
4661
5190
  host: {
@@ -4679,16 +5208,14 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
4679
5208
  delete process.env[envVar];
4680
5209
  }
4681
5210
  exposedEnvVars.clear();
4682
- if (stepTmpdir !== null) {
4683
- await fsPromises.rm(stepTmpdir, {
4684
- recursive: true,
4685
- force: true
4686
- });
4687
- stepTmpdir = null;
5211
+ if (stepTmpHandle !== null) {
5212
+ await stepTmpHandle.cleanup();
5213
+ stepTmpHandle = null;
4688
5214
  }
4689
5215
  },
4690
5216
  onDisposeError: (err) => {
4691
- origStderrWrite(`[workflow-runner] secret-file cleanup error: ${err instanceof Error ? err.message : String(err)}\n`);
5217
+ const message = err instanceof Error ? err.message : String(err);
5218
+ origStderrWrite(`[workflow-runner] secret-file cleanup error: ${message}\n`);
4692
5219
  }
4693
5220
  });
4694
5221
  }
@@ -4712,14 +5239,25 @@ function createSecretMasker(request) {
4712
5239
  * before spawning this process). This is the single shell-construction code
4713
5240
  * path shared by step execution (`createSandboxStepContext`) and the per-job
4714
5241
  * init phase (`runInitPhase`), so init commands run through the identical shell
4715
- * steps use — same cwd, same env snapshot, same masked log streaming.
5242
+ * steps use — same cwd, same live sanitized env, same masked log streaming.
5243
+ *
5244
+ * The shell binds `process.env` by reference (not a copy), so a same-step
5245
+ * ctx.setEnv / ctx.addPath mutation — which applyEnvDelta writes to live
5246
+ * process.env — is visible to that step's own subprocesses, matching the
5247
+ * "visible to this step" SDK contract.
4716
5248
  *
4717
5249
  * Intercept zx subprocess output via the log callback: zx does NOT write child
4718
5250
  * stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
4719
- * calls $.log() with { kind: 'stdout'|'stderr' }. With verbose=false the default
4720
- * log function skips stdout entirely, and with quiet=false it writes stderr to
4721
- * process.stderr. We override the log function to capture both kinds directly
4722
- * and send them as masked IPC log.line messages tagged with `stepIndex`.
5251
+ * calls $.log() with { kind: 'stdout'|'stderr', verbose }. We override the log
5252
+ * function (`makeStreamingZxLog`) to capture both kinds and send them as masked
5253
+ * IPC log.line messages tagged with `stepIndex`.
5254
+ *
5255
+ * The shell is built with `verbose: true` so ordinary subprocess output is
5256
+ * flagged `verbose: true` (captured) while a step that opts into
5257
+ * `$({ quiet: true })` — e.g. a `sops -d` credential decrypt — is flagged
5258
+ * `verbose: false` and dropped by `makeStreamingZxLog`. That gate is what keeps
5259
+ * a decrypted secret out of the streamed run log; a `verbose: false` base would
5260
+ * flag ordinary output `verbose: false` too and drop every line.
4723
5261
  *
4724
5262
  * IMPORTANT: The log function must be passed in the zx$() config, not set on the
4725
5263
  * returned function. zx$() returns a plain function (not the proxy $), so setting
@@ -4727,36 +5265,82 @@ function createSecretMasker(request) {
4727
5265
  * AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
4728
5266
  */
4729
5267
  function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
4730
- let zxLineBuf = "";
4731
5268
  return $({
4732
5269
  cwd,
4733
- env: { ...process.env },
4734
- verbose: false,
5270
+ env: process.env,
5271
+ verbose: true,
4735
5272
  quiet: false,
4736
- log: ((entry) => {
4737
- if (entry.kind === "stdout" || entry.kind === "stderr") {
4738
- const text = typeof entry.data === "string" ? entry.data : String(entry.data ?? "");
4739
- zxLineBuf += text;
4740
- const lines = zxLineBuf.split("\n");
4741
- zxLineBuf = lines.pop();
4742
- for (const line of lines) if (line) maskedSendFn({
4743
- type: "log.line",
4744
- stepIndex,
4745
- line
4746
- });
4747
- return;
4748
- }
4749
- })
5273
+ log: makeStreamingZxLog((line, stream) => maskedSendFn({
5274
+ type: "log.line",
5275
+ stepIndex,
5276
+ line,
5277
+ stream
5278
+ }))
4750
5279
  });
4751
5280
  }
4752
5281
  /**
5282
+ * Resolve the on-the-wire event name for `ctx.emit`. Accepts either an ad-hoc
5283
+ * event-name string or a `defineEvent()` definition object (passed by the typed
5284
+ * emit overload); a definition resolves to its `.name`, a string passes through.
5285
+ */
5286
+ function resolveEmitEventName(nameOrDefinition) {
5287
+ return isEventDefinition(nameOrDefinition) ? nameOrDefinition.name : nameOrDefinition;
5288
+ }
5289
+ /**
5290
+ * Sanitize a raw identifier into a valid temp label: lowercase, every
5291
+ * non-`[a-z0-9-]` char to `-`, falling back to `'step'` when the result is
5292
+ * empty. Applied to both the caller-supplied `ctx.mktemp(label)` and the
5293
+ * default step-id label so a friendly-but-irregular label never rejects at the
5294
+ * scope. Mirrors the SDK test builder's `sanitizeTempLabel`.
5295
+ */
5296
+ function sanitizeTempLabel(raw) {
5297
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, "-");
5298
+ return cleaned.length > 0 ? cleaned : "step";
5299
+ }
5300
+ /**
5301
+ * Drain the job-scoped temp allocator once at job end.
5302
+ *
5303
+ * Called after the step loop and cancel-path hooks, immediately before the
5304
+ * terminal `job.complete` emit, so a single call reclaims every `ctx.mktemp` /
5305
+ * `ctx.mktempFile` allocation on success, failure, and cancel/timeout alike. A
5306
+ * cleanup failure must NEVER change the job's terminal status, so any error is
5307
+ * swallowed into `warn` rather than thrown out of `main()`.
5308
+ */
5309
+ async function drainJobTempScope(scope, warn) {
5310
+ try {
5311
+ await scope.disposeAll();
5312
+ } catch (err) {
5313
+ warn(`job temp scope cleanup failed: ${toErrorMessage(err)}`);
5314
+ }
5315
+ }
5316
+ /**
5317
+ * Job-end wrapper around {@link drainJobTempScope} that routes a cleanup
5318
+ * warning to the run log as a job-level (`stepIndex: -1`) line. Kept out of
5319
+ * `main()` so the single drain call site stays one line.
5320
+ */
5321
+ async function drainJobTempScopeToLog(scope, send) {
5322
+ await drainJobTempScope(scope, (message) => send({
5323
+ type: "log.line",
5324
+ stepIndex: -1,
5325
+ line: `[kici] ${message}`
5326
+ }));
5327
+ }
5328
+ /**
5329
+ * Map the step loop's terminal status onto the initial job-level status —
5330
+ * `success` iff every step succeeded, else `failed`. `main()` may override this
5331
+ * afterward for the cancel/timeout paths.
5332
+ */
5333
+ function initialJobStatus(loopStatus) {
5334
+ return loopStatus === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
5335
+ }
5336
+ /**
4753
5337
  * Create a StepContext natively inside the workflow runner.
4754
5338
  *
4755
5339
  * The context is reconstructed from the environment and IPC request fields --
4756
5340
  * NOT serialized across the process boundary. This means zx $ runs natively
4757
5341
  * inside this process with full shell access.
4758
5342
  */
4759
- function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal) {
5343
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal, jobTempScope) {
4760
5344
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
4761
5345
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
4762
5346
  const rawPayload = rawPayloadFromEvent(request.event);
@@ -4800,9 +5384,11 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4800
5384
  runsOn: request.runsOn
4801
5385
  },
4802
5386
  isTestRun: request.isTestRun ?? false,
4803
- environment: request.environment,
5387
+ context: request.context,
4804
5388
  cache: createCacheApi(workDir, buildCacheTransport()),
4805
- emit: async (eventName, payload, options) => {
5389
+ artifacts: createArtifactsApi(workDir, buildArtifactTransport()),
5390
+ emit: async (nameOrDefinition, payload, options) => {
5391
+ const eventName = resolveEmitEventName(nameOrDefinition);
4806
5392
  const reqId = randomUUID();
4807
5393
  sendMessage({
4808
5394
  type: "event.emit",
@@ -4824,6 +5410,8 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4824
5410
  setSecretOutput: (key, value) => {
4825
5411
  secretOutputs.set(key, value);
4826
5412
  },
5413
+ mktemp: (label) => jobTempScope.mktemp(sanitizeTempLabel(label ?? stepName)),
5414
+ mktempFile: (label, opts) => jobTempScope.mktempFile(sanitizeTempLabel(label ?? stepName), opts),
4827
5415
  kici,
4828
5416
  attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
4829
5417
  ...rawPayload && { rawPayload },
@@ -5008,14 +5596,15 @@ async function applyOverlayIfRequested(request, workflowDir) {
5008
5596
  * Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
5009
5597
  * `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
5010
5598
  * on `process.env` here (before the step loop) means every step subprocess —
5011
- * each zx `$` snapshots `process.env` at context creation — inherits it, so git
5599
+ * each zx `$` reads live `process.env` at spawn — inherits it, so git
5012
5600
  * works in steps exactly as it does locally. We also register the dep-restore
5013
5601
  * scratch-dir exclude now that a real `.git` exists in the workspace.
5014
5602
  */
5015
5603
  async function makeOverlayGitUsable(request, workspaceDir) {
5016
5604
  if (!request.fullRepo) return;
5017
5605
  if (!existsSync(join(workspaceDir, ".git"))) return;
5018
- const cfgPath = join(await fsPromises.mkdtemp(join(tmpdir(), "kici-gitcfg-")), "config");
5606
+ const { path: cfgDir } = await makeTempDir("gitcfg");
5607
+ const cfgPath = join(cfgDir, "config");
5019
5608
  await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
5020
5609
  process.env.GIT_CONFIG_GLOBAL = cfgPath;
5021
5610
  trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
@@ -5206,7 +5795,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
5206
5795
  stepIndex: -1,
5207
5796
  line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
5208
5797
  });
5209
- const waitCapMs = Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5;
5798
+ const waitCapMs = request.concurrencyWaitTimeoutMs ?? (Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5);
5210
5799
  try {
5211
5800
  ack = await waitForConcurrencyAck(waitCapMs);
5212
5801
  } catch (waitErr) {
@@ -5395,6 +5984,46 @@ async function runCancelPathHooks(args) {
5395
5984
  };
5396
5985
  }
5397
5986
  /**
5987
+ * Coerce one raw entry (bare function or Step) into a normalized Step, naming
5988
+ * anonymous steps `step-N` with a counter shared across the whole flattened
5989
+ * sequence (parallel children inline) — matching the compiler's transformSteps
5990
+ * enumeration so the flat-stepIndex invariant holds agent ↔ orchestrator.
5991
+ *
5992
+ * Unnamed steps are named by MUTATING the original object so the SDK's
5993
+ * late-bound `.result` proxy (which reads `step.name` at access time) resolves.
5994
+ * A step object reused twice in one pass gets a clone with a fresh name on the
5995
+ * repeat encounter — mirroring the compiler's per-occurrence naming so lock
5996
+ * parity holds; the original keeps its first binding.
5997
+ */
5998
+ function coerceStep(stepOrFn, state) {
5999
+ if (typeof stepOrFn === "function") {
6000
+ state.counter++;
6001
+ const name = `step-${state.counter}`;
6002
+ state.refMap.set(stepOrFn, name);
6003
+ return {
6004
+ _tag: "Step",
6005
+ name,
6006
+ run: stepOrFn,
6007
+ outputs: void 0
6008
+ };
6009
+ }
6010
+ const s = stepOrFn;
6011
+ if (!s.name) {
6012
+ state.counter++;
6013
+ s.name = `step-${state.counter}`;
6014
+ state.autoNamed.add(s);
6015
+ return s;
6016
+ }
6017
+ if (state.autoNamed.has(s)) {
6018
+ state.counter++;
6019
+ return {
6020
+ ...s,
6021
+ name: `step-${state.counter}`
6022
+ };
6023
+ }
6024
+ return s;
6025
+ }
6026
+ /**
5398
6027
  * Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
5399
6028
  * factory when `dynamicSource` is set, otherwise looking up the static job)
5400
6029
  * and normalise the result into the `Step[]` shape the step loop consumes.
@@ -5410,30 +6039,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5410
6039
  driftDroppedJobs = dynamicResult.droppedJobs;
5411
6040
  if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
5412
6041
  } else rawSteps = extractSteps(workflow, request.jobName);
5413
- const refMap = /* @__PURE__ */ new WeakMap();
5414
- let stepCounter = 0;
5415
- const coerce = (stepOrFn) => {
5416
- if (typeof stepOrFn === "function") {
5417
- stepCounter++;
5418
- const name = `step-${stepCounter}`;
5419
- refMap.set(stepOrFn, name);
5420
- return {
5421
- _tag: "Step",
5422
- name,
5423
- run: stepOrFn,
5424
- outputs: void 0
5425
- };
5426
- }
5427
- const s = stepOrFn;
5428
- if (!s.name) {
5429
- stepCounter++;
5430
- return {
5431
- ...s,
5432
- name: `step-${stepCounter}`
5433
- };
5434
- }
5435
- return s;
6042
+ const coerceState = {
6043
+ counter: 0,
6044
+ refMap: /* @__PURE__ */ new WeakMap(),
6045
+ autoNamed: /* @__PURE__ */ new WeakSet()
5436
6046
  };
6047
+ const refMap = coerceState.refMap;
6048
+ const coerce = (stepOrFn) => coerceStep(stepOrFn, coerceState);
5437
6049
  const normalizedSteps = [];
5438
6050
  const nodes = [];
5439
6051
  let flatIndex = 0;
@@ -5480,9 +6092,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5480
6092
  * Phase 6 — Build the output infrastructure: the per-step operator-secret
5481
6093
  * key set (used for setEnv override protection), and the output / job-output
5482
6094
  * maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
5483
- * Sets the SDK module globals as a side effect.
6095
+ * Sets the SDK module globals as a side effect — on BOTH the agent's bundled
6096
+ * SDK and the workflow module's own SDK instance (`workflowSdkSetters`), with
6097
+ * the same map objects. The workflow's within-job `.result` proxies read that
6098
+ * instance's module-global step-outputs map, so wiring only the agent's bundled
6099
+ * SDK would leave the proxies reading an always-empty map.
5484
6100
  */
5485
- function buildOutputInfrastructure(request, refMap) {
6101
+ function buildOutputInfrastructure(request, refMap, workflowSdkSetters) {
5486
6102
  const operatorSecretKeys = /* @__PURE__ */ new Set();
5487
6103
  if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
5488
6104
  if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
@@ -5490,9 +6106,12 @@ function buildOutputInfrastructure(request, refMap) {
5490
6106
  const secretOutputs = /* @__PURE__ */ new Map();
5491
6107
  setStepOutputsMap(outputsMap);
5492
6108
  setStepRefMap(refMap);
6109
+ workflowSdkSetters?.setStepOutputsMap(outputsMap);
6110
+ workflowSdkSetters?.setStepRefMap(refMap);
5493
6111
  const jobOutputsMap = /* @__PURE__ */ new Map();
5494
6112
  if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
5495
6113
  setJobOutputsMap(jobOutputsMap);
6114
+ workflowSdkSetters?.setJobOutputsMap(jobOutputsMap);
5496
6115
  return {
5497
6116
  operatorSecretKeys,
5498
6117
  outputsMap,
@@ -5501,27 +6120,82 @@ function buildOutputInfrastructure(request, refMap) {
5501
6120
  };
5502
6121
  }
5503
6122
  /**
5504
- * Phase 7 Evaluate job-level rules. When any rule fails, send a
5505
- * `job.complete{success}` with all steps marked skipped and exit 0.
5506
- * Returns false when the caller should continue to step execution.
6123
+ * Resolve `event.changedFiles` for rule evaluation before job/step rules run.
6124
+ * Ground truth is the agent's own clone (`computeChangedFiles`); the
6125
+ * orchestrator's already-fetched list (status `'fetched'`) is a free fast-path.
6126
+ * Only runs when a rule could read `ctx.changedFiles` — rule-less jobs skip the
6127
+ * git cost. Diff-less events (schedule/tag/manual) resolve to `'unavailable'`.
5507
6128
  */
5508
- async function maybeSkipJobOnRules(job, request, normalizedSteps) {
5509
- if (!job?.rules || job.rules.length === 0) return false;
5510
- const ruleCtx = createRuleContext(request.event ?? {}, [], process.env, request.dispatchInputs ?? {}, deriveFanout(request));
5511
- if ((await evaluateRules(job.rules, ruleCtx, request.jobName)).allPassed) return false;
6129
+ async function resolveChangedFilesForRules(request, workDir, hasRules) {
6130
+ if (!hasRules) return;
6131
+ request.event ??= {};
6132
+ const ev = request.event;
6133
+ if (ev.changedFilesStatus === "fetched") return;
6134
+ const auth = request.sourceAuth ?? request.workflowAuth ?? (request.token ? {
6135
+ kind: "basic",
6136
+ user: "x-access-token",
6137
+ secret: request.token
6138
+ } : void 0);
6139
+ const resolved = await computeChangedFiles(workDir, request.event, auth);
6140
+ ev.changedFiles = resolved.files;
6141
+ ev.changedFilesStatus = resolved.status;
6142
+ }
6143
+ /**
6144
+ * Decide the terminal `job.complete` for job-level rule evaluation, or `null`
6145
+ * when the job should proceed to step execution.
6146
+ *
6147
+ * - `allPassed` → `null` (run the steps).
6148
+ * - a rule's `check()` **threw** (`evaluationError`) → FAIL: the gate could not
6149
+ * be evaluated, so treating "couldn't decide" as "don't run" would be a false
6150
+ * green. Report `status: failed` with the error surfaced.
6151
+ * - a rule cleanly returned `false` → clean skip: `status: success` with every
6152
+ * step marked skipped (a gate that evaluated and said "don't run").
6153
+ *
6154
+ * Pure and exported so the decision is unit-testable without `process.exit`.
6155
+ */
6156
+ function buildJobRuleCompletion(ruleResult, normalizedSteps) {
6157
+ if (ruleResult.allPassed) return null;
5512
6158
  const skippedResults = normalizedSteps.map((s, i) => ({
5513
6159
  name: s.name,
5514
6160
  stepIndex: i,
5515
6161
  status: ExecutionStepStatus.enum.skipped,
5516
6162
  durationMs: 0
5517
6163
  }));
5518
- flushOutputCapture();
5519
- capturePrepareActive = false;
5520
- sendMessage({
6164
+ if (ruleResult.evaluationError) return {
6165
+ type: "job.complete",
6166
+ status: ExecutionJobStatus.enum.failed,
6167
+ stepResults: skippedResults,
6168
+ error: `rule '${ruleResult.evaluationError.label}' errored: ${ruleResult.evaluationError.message}`
6169
+ };
6170
+ return {
5521
6171
  type: "job.complete",
5522
6172
  status: ExecutionJobStatus.enum.success,
5523
6173
  stepResults: skippedResults
6174
+ };
6175
+ }
6176
+ /**
6177
+ * Phase 7 — Evaluate job-level rules. A clean rule failure sends
6178
+ * `job.complete{success}` with all steps skipped; a rule whose `check()` threw
6179
+ * sends `job.complete{failed}` with the error surfaced (never a silent
6180
+ * success-skip). Returns false when the caller should continue to step
6181
+ * execution.
6182
+ */
6183
+ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
6184
+ if (!job?.rules || job.rules.length === 0) return false;
6185
+ const ev = request.event ?? {};
6186
+ const ruleCtx = createRuleContext({
6187
+ event: request.event ?? {},
6188
+ changedFiles: ev.changedFiles,
6189
+ changedFilesStatus: ev.changedFilesStatus,
6190
+ env: process.env,
6191
+ dispatchInputs: request.dispatchInputs ?? {},
6192
+ fanout: deriveFanout(request)
5524
6193
  });
6194
+ const completion = buildJobRuleCompletion(await evaluateRules(job.rules, ruleCtx, request.jobName), normalizedSteps);
6195
+ if (!completion) return false;
6196
+ flushOutputCapture();
6197
+ capturePrepareActive = false;
6198
+ sendMessage(completion);
5525
6199
  process.exit(0);
5526
6200
  }
5527
6201
  /**
@@ -5571,8 +6245,8 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
5571
6245
  * Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
5572
6246
  * pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
5573
6247
  * step's pair and points the runner's process.env at it (each step's zx $
5574
- * snapshots process.env at context creation, which happens AFTER this
5575
- * before-hook, so the shell sees them; the pre-fork env allowlist does not
6248
+ * reads live process.env at spawn, which happens AFTER this before-hook, so
6249
+ * the shell sees them; the pre-fork env allowlist does not
5576
6250
  * re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
5577
6251
  * step's delta and releases the pair.
5578
6252
  *
@@ -5645,12 +6319,13 @@ async function runInitPhaseOrFailJob(args) {
5645
6319
  })
5646
6320
  });
5647
6321
  if (initSpecs.length === 0) return;
6322
+ const initCache = createCacheApi(stepCwd, buildCacheTransport());
5648
6323
  const initResult = await runInitPhase({
5649
6324
  specs: initSpecs,
5650
6325
  shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
5651
6326
  sendIpc: maskedSend,
5652
6327
  stepIndexBase: INIT_STEP_INDEX_BASE,
5653
- cache: createCacheApi(stepCwd, buildCacheTransport()),
6328
+ cache: initCache,
5654
6329
  env: {
5655
6330
  beginCapture: async () => {
5656
6331
  process.env.KICI_ENV = envFiles.envFile;
@@ -5723,7 +6398,8 @@ async function main() {
5723
6398
  await installDependenciesIfNeeded(workflowDir, request);
5724
6399
  if (aborted) abortAndExit("aborted after deps");
5725
6400
  await restoreSourceTarballIfRequested(workflowDir, request);
5726
- const module = (await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend)).module;
6401
+ const loaded = await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend);
6402
+ const module = loaded.module;
5727
6403
  const workflow = extractWorkflow(module, request.workflowName);
5728
6404
  await evaluateConcurrencyGroupIfPresent(workflow, request);
5729
6405
  const apiTransport = async (method, params) => {
@@ -5737,8 +6413,11 @@ async function main() {
5737
6413
  return waitForApiResponse(reqId);
5738
6414
  };
5739
6415
  const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5740
- const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
6416
+ const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
5741
6417
  const job = findJob(workflow, request.jobName);
6418
+ const jobHasRules = (job?.rules?.length ?? 0) > 0;
6419
+ const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
6420
+ await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
5742
6421
  await maybeSkipJobOnRules(job, request, normalizedSteps);
5743
6422
  if (aborted) abortAndExit("aborted after rules");
5744
6423
  const jobHooks = collectJobHooks(job);
@@ -5756,6 +6435,7 @@ async function main() {
5756
6435
  const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
5757
6436
  const stepTasks = new StepTaskRegistry();
5758
6437
  const stepAbortControllers = /* @__PURE__ */ new Map();
6438
+ const jobTempScope = createTempScope();
5759
6439
  const createStepCtxWithCapture = (stepIndex, stepName) => {
5760
6440
  const stepAbort = new AbortController();
5761
6441
  stepAbortControllers.set(stepIndex, stepAbort);
@@ -5769,7 +6449,7 @@ async function main() {
5769
6449
  secrets: handle.secrets,
5770
6450
  dispose: handle.dispose
5771
6451
  });
5772
- const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal);
6452
+ const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal, jobTempScope);
5773
6453
  if (globalRepoInfo) {
5774
6454
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
5775
6455
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
@@ -5814,7 +6494,7 @@ async function main() {
5814
6494
  });
5815
6495
  jobDeadline.clear();
5816
6496
  await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
5817
- let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
6497
+ let finalStatus = initialJobStatus(loopResult.status);
5818
6498
  let cancelFailureReason;
5819
6499
  if (aborted) {
5820
6500
  const cancelResult = await runCancelPathHooks({
@@ -5835,6 +6515,7 @@ async function main() {
5835
6515
  cancelFailureReason = cancelResult.cancelFailureReason;
5836
6516
  }
5837
6517
  if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
6518
+ await drainJobTempScopeToLog(jobTempScope, maskedSend);
5838
6519
  emitJobComplete({
5839
6520
  finalStatus,
5840
6521
  loopResult,
@@ -5894,6 +6575,6 @@ main().catch((error) => {
5894
6575
  setTimeout(() => process.exit(1), 100);
5895
6576
  });
5896
6577
  //#endregion
5897
- export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
6578
+ export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
5898
6579
 
5899
6580
  //# sourceMappingURL=workflow-runner.js.map