@kici-dev/agent 0.1.27 → 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 (37) 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 +36 -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/job-runner.d.ts +24 -6
  15. package/dist/execution/log-streamer.d.ts +17 -2
  16. package/dist/execution/rule-evaluator.d.ts +1 -12
  17. package/dist/execution/sandbox/container-hardening.d.ts +80 -0
  18. package/dist/execution/sandbox/container-sandbox.d.ts +54 -0
  19. package/dist/execution/sandbox/container-ts-loader-hook.d.ts +26 -0
  20. package/dist/execution/sandbox/fork-runner.d.ts +14 -0
  21. package/dist/execution/sandbox/index.d.ts +2 -1
  22. package/dist/execution/sandbox/ipc-protocol.d.ts +79 -3
  23. package/dist/execution/sandbox/step-loop.d.ts +4 -0
  24. package/dist/execution/sandbox/types.d.ts +25 -4
  25. package/dist/execution/sandbox/workflow-runner.d.ts +111 -5
  26. package/dist/execution/streaming-zx-log.d.ts +11 -3
  27. package/dist/execution/tmp-gc.d.ts +22 -7
  28. package/dist/execution/workflow-loader.d.ts +32 -1
  29. package/dist/index.js +44 -45
  30. package/dist/provenance/statement-builder.d.ts +3 -2
  31. package/dist/server.d.ts +1 -1
  32. package/dist/server.js +1256 -171
  33. package/dist/workflow-runner-bundle.js +215393 -0
  34. package/dist/workflow-runner.js +886 -244
  35. package/dist/ws/orchestrator-client.d.ts +105 -1
  36. package/package.json +14 -12
  37. package/sbom.spdx.json +1090 -1721
@@ -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";
@@ -74,28 +75,281 @@ var __exportAll = (all, no_symbols) => {
74
75
  * `verbose: false` (suppressed). A `verbose: false` base would flag ordinary
75
76
  * output `verbose: false` too, and this gate would then drop every line.
76
77
  *
77
- * The returned callback owns a per-shell line buffer, so partial chunks are
78
- * coalesced into whole lines before `emit` is called.
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.
79
87
  */
80
88
  function makeStreamingZxLog(emit) {
81
- let lineBuf = "";
89
+ const lineBufs = {
90
+ [LogStream.enum.stdout]: "",
91
+ [LogStream.enum.stderr]: ""
92
+ };
82
93
  return (entry) => {
83
94
  const e = entry;
84
95
  if (e.kind !== "stdout" && e.kind !== "stderr") return;
85
96
  if (!e.verbose) return;
97
+ const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
86
98
  const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
87
- lineBuf += text;
88
- const lines = lineBuf.split("\n");
89
- lineBuf = lines.pop();
90
- for (const line of lines) if (line) emit(line);
99
+ const lines = (lineBufs[stream] + text).split("\n");
100
+ lineBufs[stream] = lines.pop();
101
+ for (const line of lines) if (line) emit(line, stream);
91
102
  };
92
103
  }
93
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
94
347
  //#region src/provenance/statement-builder.ts
95
348
  /**
96
349
  * Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
97
350
  * token claims plus the caller-supplied subject. The build context comes
98
- * 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
99
353
  * statement's identity equals the token's identity by construction.
100
354
  */
101
355
  /**
@@ -427,7 +681,7 @@ async function excludeScratchFromGit(repoWorkDir) {
427
681
  const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
428
682
  await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
429
683
  } catch (err) {
430
- 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", {
431
685
  excludePath,
432
686
  error: err instanceof Error ? err.message : String(err)
433
687
  });
@@ -488,7 +742,7 @@ async function cleanupScratch(scratchDir) {
488
742
  force: true
489
743
  });
490
744
  } catch (cleanupErr) {
491
- logger$4.warn("Scratch dir cleanup failed (orphan left behind)", {
745
+ logger$5.warn("Scratch dir cleanup failed (orphan left behind)", {
492
746
  scratchDir,
493
747
  error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
494
748
  });
@@ -513,7 +767,7 @@ async function cleanupScratch(scratchDir) {
513
767
  */
514
768
  async function restoreDeps(workDir, depsUrl, depsHash) {
515
769
  depsUrl = resolveOrchestratorUrl(depsUrl);
516
- logger$4.info("Downloading dependency tarball", { url: depsUrl });
770
+ logger$5.info("Downloading dependency tarball", { url: depsUrl });
517
771
  const kiciDir = join(workDir, ".kici");
518
772
  if (depsUrl.startsWith("file://")) {
519
773
  const localPath = fileURLToPath(depsUrl);
@@ -527,7 +781,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
527
781
  await moveScratchIntoRepo(scratchDir, workDir);
528
782
  await cleanupScratch(scratchDir);
529
783
  const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
530
- logger$4.info("Dependencies restored from cache (file)", {
784
+ logger$5.info("Dependencies restored from cache (file)", {
531
785
  sizeMB,
532
786
  targetDir: workDir
533
787
  });
@@ -536,7 +790,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
536
790
  if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
537
791
  let lastError;
538
792
  for (let attempt = 0; attempt <= 2; attempt++) {
539
- if (attempt > 0) logger$4.warn("Retrying dep tarball download", {
793
+ if (attempt > 0) logger$5.warn("Retrying dep tarball download", {
540
794
  attempt,
541
795
  url: depsUrl
542
796
  });
@@ -545,11 +799,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
545
799
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
546
800
  await moveScratchIntoRepo(scratchDir, workDir);
547
801
  await cleanupScratch(scratchDir);
548
- logger$4.info("Dependencies restored from cache (stream)", { targetDir: workDir });
802
+ logger$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
549
803
  return;
550
804
  } catch (err) {
551
805
  lastError = err instanceof Error ? err : new Error(String(err));
552
- logger$4.warn("Dep tarball download failed", {
806
+ logger$5.warn("Dep tarball download failed", {
553
807
  attempt,
554
808
  error: lastError.message
555
809
  });
@@ -557,9 +811,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
557
811
  }
558
812
  throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
559
813
  }
560
- 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;
561
815
  var init_dep_restore = __esmMin((() => {
562
- logger$4 = createLogger({ prefix: "dep-restore" });
816
+ logger$5 = createLogger({ prefix: "dep-restore" });
563
817
  DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
564
818
  SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
565
819
  SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
@@ -664,13 +918,9 @@ init_download();
664
918
  * into place so a partial restore never leaves half-written paths in the live
665
919
  * tree (mirrors dep-restore).
666
920
  */
667
- const logger$3 = createLogger({ prefix: "cache-engine" });
921
+ const logger$4 = createLogger({ prefix: "cache-engine" });
668
922
  /** Download timeout for a presigned cache GET: 5 minutes. */
669
923
  const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
670
- /** Anchor prefix for repo-root-relative cache entries inside the tar. */
671
- const REPO_ANCHOR = "__repo__";
672
- /** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
673
- const HOME_ANCHOR = "__home__";
674
924
  /**
675
925
  * Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
676
926
  * Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
@@ -711,7 +961,7 @@ function anchorEntries(workDir, paths, roots) {
711
961
  */
712
962
  async function packCachePaths(workDir, paths, roots) {
713
963
  const entries = anchorEntries(workDir, paths, roots);
714
- const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
964
+ const { path: staging, cleanup } = await makeTempDir("cache-pack");
715
965
  try {
716
966
  const topLevel = /* @__PURE__ */ new Set();
717
967
  for (const e of entries) {
@@ -732,7 +982,7 @@ async function packCachePaths(workDir, paths, roots) {
732
982
  for await (const chunk of stream) chunks.push(Buffer.from(chunk));
733
983
  const tarball = Buffer.concat(chunks);
734
984
  const hash = sha256(tarball);
735
- logger$3.info("packed user cache", {
985
+ logger$4.info("packed user cache", {
736
986
  sizeBytes: tarball.length,
737
987
  hash: hash.slice(0, 12),
738
988
  paths
@@ -742,10 +992,7 @@ async function packCachePaths(workDir, paths, roots) {
742
992
  hash
743
993
  };
744
994
  } finally {
745
- await rm(staging, {
746
- recursive: true,
747
- force: true
748
- });
995
+ await cleanup();
749
996
  }
750
997
  }
751
998
  /** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
@@ -781,17 +1028,14 @@ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
781
1028
  cb(null, chunk);
782
1029
  } });
783
1030
  await mkdir(workDir, { recursive: true });
784
- const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
1031
+ const { path: scratch, cleanup } = await makeTempDir("cache-extract");
785
1032
  try {
786
1033
  await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
787
1034
  const digest = hash.digest("hex");
788
1035
  if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
789
1036
  await moveAnchoredGroups(scratch, workDir, home);
790
1037
  } finally {
791
- await rm(scratch, {
792
- recursive: true,
793
- force: true
794
- });
1038
+ await cleanup();
795
1039
  }
796
1040
  }
797
1041
  /** Build the imperative `ctx.cache` API bound to a workDir + transport. */
@@ -801,7 +1045,7 @@ function createCacheApi(workDir, transport, roots) {
801
1045
  const r = await transport.restore(spec.key, spec.restoreKeys);
802
1046
  if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
803
1047
  await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
804
- logger$3.info("user cache restored", {
1048
+ logger$4.info("user cache restored", {
805
1049
  key: spec.key,
806
1050
  matchedKey: r.matchedKey
807
1051
  });
@@ -813,14 +1057,14 @@ function createCacheApi(workDir, transport, roots) {
813
1057
  async save(spec) {
814
1058
  const begin = await transport.beginSave(spec.key);
815
1059
  if (begin.skip || !begin.uploadUrl) {
816
- 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 });
817
1061
  return;
818
1062
  }
819
1063
  const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
820
1064
  const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
821
1065
  await uploadToPresignedUrl(begin.uploadUrl, tarball);
822
1066
  await transport.completeSave(spec.key, hash, tarball.length);
823
- logger$3.info("user cache saved", {
1067
+ logger$4.info("user cache saved", {
824
1068
  key: spec.key,
825
1069
  sizeBytes: tarball.length
826
1070
  });
@@ -945,6 +1189,98 @@ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
945
1189
  }
946
1190
  }
947
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
948
1284
  //#region src/execution/sandbox/log-masker.ts
949
1285
  /**
950
1286
  * Secret value masking for log lines.
@@ -1103,7 +1439,7 @@ function parsePathFileContent(content) {
1103
1439
  }
1104
1440
  /** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
1105
1441
  async function createEnvFiles(baseDir) {
1106
- const dir = await mkdtemp(join(baseDir, "kici-env-"));
1442
+ const { path: dir } = await makeTempDir("env", { base: baseDir });
1107
1443
  const envFile = join(dir, "env");
1108
1444
  const pathFile = join(dir, "path");
1109
1445
  await writeFile(envFile, "");
@@ -1243,25 +1579,6 @@ async function executeHook(opts) {
1243
1579
  //#endregion
1244
1580
  //#region src/execution/rule-evaluator.ts
1245
1581
  initZx();
1246
- /**
1247
- * Create RuleContext for agent-side rule evaluation.
1248
- *
1249
- * @param event - Event payload from the dispatch message
1250
- * @param changedFiles - List of files changed in this event
1251
- * @param env - Merged environment variables
1252
- * @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
1253
- * @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
1254
- */
1255
- function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs = {}, fanout) {
1256
- return {
1257
- event,
1258
- changedFiles,
1259
- env,
1260
- dispatchInputs,
1261
- ...fanout && { fanout },
1262
- $
1263
- };
1264
- }
1265
1582
  //#endregion
1266
1583
  //#region src/execution/sandbox/parallel-scheduler.ts
1267
1584
  /**
@@ -1483,6 +1800,58 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opt
1483
1800
  ...res.drift != null && { drift: res.drift }
1484
1801
  };
1485
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
+ }
1486
1855
  /**
1487
1856
  * Execute a single step with timeout enforcement.
1488
1857
  *
@@ -1573,6 +1942,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1573
1942
  }
1574
1943
  const exitCode = extractExitCode(e);
1575
1944
  const signal = extractSignal(e);
1945
+ emitStepFailureLog(step.name, stepIndex, error.message, sendFn);
1576
1946
  const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1577
1947
  emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1578
1948
  sendFn({
@@ -1623,14 +1993,60 @@ function extractSignal(error) {
1623
1993
  if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
1624
1994
  }
1625
1995
  /**
1626
- * Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
1627
- * 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).
1628
2004
  */
1629
2005
  async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1630
2006
  if (!step.rules || step.rules.length === 0) return null;
1631
- 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
+ });
1632
2016
  const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
1633
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
+ }
1634
2050
  opts.sendIpc({
1635
2051
  type: "step.start",
1636
2052
  stepIndex,
@@ -1648,10 +2064,13 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1648
2064
  line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
1649
2065
  });
1650
2066
  return {
1651
- name: step.name,
1652
- stepIndex,
1653
- status: ExecutionStepStatus.enum.skipped,
1654
- durationMs: 0
2067
+ result: {
2068
+ name: step.name,
2069
+ stepIndex,
2070
+ status: ExecutionStepStatus.enum.skipped,
2071
+ durationMs: 0
2072
+ },
2073
+ shouldBreak: false
1655
2074
  };
1656
2075
  }
1657
2076
  /**
@@ -1701,7 +2120,8 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
1701
2120
  opts.sendIpc({
1702
2121
  type: "log.line",
1703
2122
  stepIndex,
1704
- line: `[kici] Step '${step.name}' ${why}.`
2123
+ line: `[kici] Step '${step.name}' ${why}.`,
2124
+ stream: LogStream.enum.stderr
1705
2125
  });
1706
2126
  await opts.disposeStepResources?.(stepIndex);
1707
2127
  return {
@@ -1742,7 +2162,8 @@ async function runObserverHook(args) {
1742
2162
  if (!hookResult.success) opts.sendIpc({
1743
2163
  type: "log.line",
1744
2164
  stepIndex,
1745
- 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
1746
2167
  });
1747
2168
  }
1748
2169
  /**
@@ -1782,20 +2203,18 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
1782
2203
  opts.sendIpc({
1783
2204
  type: "log.line",
1784
2205
  stepIndex,
1785
- 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
1786
2208
  });
1787
2209
  await new Promise((r) => setTimeout(r, delay));
1788
2210
  }
1789
2211
  return result;
1790
2212
  }
1791
2213
  async function runStepIteration(step, stepIndex, opts) {
1792
- const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1793
- if (skippedResult) {
2214
+ const ruleOutcome = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
2215
+ if (ruleOutcome) {
1794
2216
  await opts.disposeStepResources?.(stepIndex);
1795
- return {
1796
- result: skippedResult,
1797
- shouldBreak: false
1798
- };
2217
+ return ruleOutcome;
1799
2218
  }
1800
2219
  const gate = await maybeGateStepApproval(step, stepIndex, opts);
1801
2220
  if (gate) return gate;
@@ -2428,63 +2847,6 @@ function armJobDeadline(timeoutMs, onTimeout) {
2428
2847
  return { clear: () => clearTimeout(timer) };
2429
2848
  }
2430
2849
  //#endregion
2431
- //#region src/checkout/ssh-auth.ts
2432
- /**
2433
- * Materialize an SSH private key (and optional pinned known_hosts) into a
2434
- * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
2435
- *
2436
- * Permissions:
2437
- * - private key mode 0o600 (required by OpenSSH — refuses to use world-
2438
- * readable keys).
2439
- * - known_hosts mode 0o600.
2440
- * - tempdir mode 0o700.
2441
- *
2442
- * SSH flags composed:
2443
- * - `-i <keyfile>` — identity file.
2444
- * - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
2445
- * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
2446
- * - host-key checking flags based on `hostKeyPolicy`.
2447
- */
2448
- async function setupSshAuth(opts) {
2449
- if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
2450
- const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
2451
- const keyPath = join(tempDir, "id");
2452
- await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
2453
- const knownHostsPath = join(tempDir, "known_hosts");
2454
- await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
2455
- const parts = [
2456
- "ssh",
2457
- "-i",
2458
- escapeShellArg(keyPath),
2459
- "-o",
2460
- "IdentitiesOnly=yes",
2461
- "-o",
2462
- "BatchMode=yes",
2463
- "-o",
2464
- `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
2465
- ];
2466
- if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
2467
- else parts.push("-o", "StrictHostKeyChecking=accept-new");
2468
- return {
2469
- gitSshCommand: parts.join(" "),
2470
- tempDir,
2471
- async cleanup() {
2472
- await rm(tempDir, {
2473
- recursive: true,
2474
- force: true
2475
- });
2476
- }
2477
- };
2478
- }
2479
- /**
2480
- * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
2481
- * wrapping so backslashes and spaces survive git's shell-parse of the
2482
- * command value.
2483
- */
2484
- function escapeShellArg(value) {
2485
- return `'${value.replace(/'/g, "'\\''")}'`;
2486
- }
2487
- //#endregion
2488
2850
  //#region src/checkout/git-clone.ts
2489
2851
  /**
2490
2852
  * Strip auth credentials from git error messages to prevent token leakage.
@@ -2524,19 +2886,16 @@ async function gitClone(options) {
2524
2886
  let needsCustomEnv = false;
2525
2887
  let safeDirCleanup;
2526
2888
  if (repoUrl.startsWith("file://")) {
2527
- const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
2528
- const { tmpdir } = await import("node:os");
2889
+ const { writeFile } = await import("node:fs/promises");
2529
2890
  const path = await import("node:path");
2530
- 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");
2531
2893
  const cfgPath = path.join(dir, "config");
2532
2894
  await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
2533
2895
  envEntries.GIT_CONFIG_GLOBAL = cfgPath;
2534
2896
  needsCustomEnv = true;
2535
2897
  safeDirCleanup = async () => {
2536
- await rm(dir, {
2537
- recursive: true,
2538
- force: true
2539
- }).catch(() => {});
2898
+ await cleanup().catch(() => {});
2540
2899
  };
2541
2900
  }
2542
2901
  let sshSetup;
@@ -2831,7 +3190,7 @@ async function applyYarnrcBerryConfig(args) {
2831
3190
  const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
2832
3191
  const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
2833
3192
  const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
2834
- const cacheFolder = await mkdtemp(join(tmpdir(), "kici-yarn-berry-cache-"));
3193
+ const { path: cacheFolder, cleanup: cleanupCache } = await makeTempDir("yarn-berry-cache");
2835
3194
  const merged = {
2836
3195
  ...doc,
2837
3196
  nodeLinker: "node-modules",
@@ -2868,10 +3227,7 @@ async function applyYarnrcBerryConfig(args) {
2868
3227
  if (original === null) await unlink(yarnrcPath).catch(() => {});
2869
3228
  else await writeFile(yarnrcPath, original, { encoding: "utf8" });
2870
3229
  } catch {}
2871
- await rm(cacheFolder, {
2872
- recursive: true,
2873
- force: true
2874
- }).catch(() => {});
3230
+ await cleanupCache().catch(() => {});
2875
3231
  };
2876
3232
  return {
2877
3233
  extraEnv: {
@@ -3186,9 +3542,9 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
3186
3542
  * Install `.kici/` dependencies inline with the repo's package manager.
3187
3543
  *
3188
3544
  * Falls back to this when the dep cache is unavailable or a download fails.
3189
- * The install runs with an isolated cache/store directory (created in
3190
- * `os.tmpdir()`) to prevent cache poisoning between build jobs; the directory
3191
- * 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.
3192
3548
  *
3193
3549
  * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
3194
3550
  * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
@@ -3277,7 +3633,7 @@ function envWithNodeOnPath(extraEnv, nodeDir) {
3277
3633
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
3278
3634
  async function runNpmInstall(args) {
3279
3635
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
3280
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
3636
+ const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
3281
3637
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3282
3638
  const buildArgs = (...prefix) => {
3283
3639
  const a = [
@@ -3302,10 +3658,7 @@ async function runNpmInstall(args) {
3302
3658
  maxBuffer: INSTALL_MAX_BUFFER
3303
3659
  });
3304
3660
  } finally {
3305
- await rm(cacheDir, {
3306
- recursive: true,
3307
- force: true
3308
- }).catch(() => {});
3661
+ await cleanup().catch(() => {});
3309
3662
  }
3310
3663
  }
3311
3664
  /**
@@ -3319,7 +3672,7 @@ async function runNpmInstall(args) {
3319
3672
  async function runPnpmInstall(args) {
3320
3673
  await assertPnpmAvailable();
3321
3674
  const { nodeDir } = resolveNpm();
3322
- const storeDir = await mkdtemp(join(tmpdir(), "kici-pnpm-store-"));
3675
+ const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
3323
3676
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3324
3677
  const argv = [
3325
3678
  "install",
@@ -3339,10 +3692,7 @@ async function runPnpmInstall(args) {
3339
3692
  maxBuffer: INSTALL_MAX_BUFFER
3340
3693
  });
3341
3694
  } finally {
3342
- await rm(storeDir, {
3343
- recursive: true,
3344
- force: true
3345
- }).catch(() => {});
3695
+ await cleanup().catch(() => {});
3346
3696
  }
3347
3697
  }
3348
3698
  /** Pure: argv for `yarn install` with an isolated cache folder. */
@@ -3368,7 +3718,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
3368
3718
  async function runYarnInstall(args) {
3369
3719
  await assertYarnAvailable();
3370
3720
  const { nodeDir } = resolveNpm();
3371
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-yarn-cache-"));
3721
+ const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
3372
3722
  const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3373
3723
  const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
3374
3724
  try {
@@ -3380,10 +3730,7 @@ async function runYarnInstall(args) {
3380
3730
  maxBuffer: INSTALL_MAX_BUFFER
3381
3731
  });
3382
3732
  } finally {
3383
- await rm(cacheDir, {
3384
- recursive: true,
3385
- force: true
3386
- }).catch(() => {});
3733
+ await cleanup().catch(() => {});
3387
3734
  }
3388
3735
  }
3389
3736
  /** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
@@ -3536,19 +3883,65 @@ function logSubprocessStreams(e, tokens) {
3536
3883
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3537
3884
  * Node's normal ESM lookup against `.kici/node_modules/`.
3538
3885
  */
3539
- const AGENT_SDK_VERSION = "0.1.27";
3540
- const AGENT_SDK_BUNDLE_HASH = "e06bd1acf349ba365bdb357f3d865e0cbb4ad4604a0270b707a681a4f440954e";
3541
3886
  /**
3542
- * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3543
- * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
3544
- * fly. Idempotent at our level via the `hookRegistered` flag; Node also
3545
- * tolerates repeated `register()` calls by stacking layers, but we avoid the
3546
- * 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.
3547
3938
  */
3548
3939
  let hookRegistered = false;
3549
3940
  function ensureLoaderHookRegistered() {
3550
3941
  if (hookRegistered) return;
3551
- 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);
3552
3945
  hookRegistered = true;
3553
3946
  }
3554
3947
  /**
@@ -3603,7 +3996,10 @@ async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, reso
3603
3996
  const actualHash = computeContentHash(rawSource, assetDigest);
3604
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.`);
3605
3998
  }
3606
- 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
+ };
3607
4003
  }
3608
4004
  /**
3609
4005
  * Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
@@ -3800,7 +4196,7 @@ function decryptBuffer(encrypted, aesKey) {
3800
4196
  */
3801
4197
  async function applyOverlay(config) {
3802
4198
  const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
3803
- const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "kici-overlay-"));
4199
+ const { path: tmpDir, cleanup } = await makeTempDir("overlay");
3804
4200
  try {
3805
4201
  logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
3806
4202
  let encryptedData;
@@ -3874,10 +4270,7 @@ async function applyOverlay(config) {
3874
4270
  verified: true
3875
4271
  };
3876
4272
  } finally {
3877
- await fsPromises.rm(tmpDir, {
3878
- recursive: true,
3879
- force: true
3880
- }).catch(() => {});
4273
+ await cleanup().catch(() => {});
3881
4274
  }
3882
4275
  }
3883
4276
  //#endregion
@@ -3899,7 +4292,7 @@ async function applyOverlay(config) {
3899
4292
  */
3900
4293
  init_download();
3901
4294
  init_dep_restore();
3902
- const AGENT_VERSION = "0.1.27";
4295
+ const AGENT_VERSION = "0.2.0";
3903
4296
  process.on("uncaughtException", (err) => {
3904
4297
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3905
4298
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3986,7 +4379,8 @@ function installOutputCapture() {
3986
4379
  for (const line of lines) if (line) captureSendFn({
3987
4380
  type: "log.line",
3988
4381
  stepIndex: stepIdx,
3989
- line
4382
+ line,
4383
+ stream: LogStream.enum.stderr
3990
4384
  });
3991
4385
  }
3992
4386
  return origStderrWrite(chunk, encodingOrCb, cb);
@@ -4239,6 +4633,33 @@ function waitForProvenanceResponse(requestId) {
4239
4633
  });
4240
4634
  });
4241
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
+ }
4242
4663
  /** Wait for a cache.response from the agent with the given requestId. */
4243
4664
  function waitForCacheResponse(requestId) {
4244
4665
  return new Promise((resolve, reject) => {
@@ -4393,6 +4814,68 @@ function buildCacheTransport() {
4393
4814
  }
4394
4815
  };
4395
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
+ }
4396
4879
  /** Send a `provenance.request` IPC and await the matching `provenance.response`. */
4397
4880
  async function relayProvenanceIpc(request) {
4398
4881
  const requestId = randomUUID();
@@ -4548,6 +5031,9 @@ function dispatchAgentMessage(msg) {
4548
5031
  } else if (msg.type === "provenance.response") {
4549
5032
  const pending = pendingProvenanceResponses.get(msg.requestId);
4550
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);
4551
5037
  } else if (msg.type === "approval.resolved") {
4552
5038
  const pending = pendingApprovalResolutions.get(msg.requestId);
4553
5039
  if (pending) pending.resolve(msg);
@@ -4692,13 +5178,13 @@ function envelopeChildOutputs(envelope) {
4692
5178
  */
4693
5179
  function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
4694
5180
  const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
4695
- let stepTmpdir = null;
5181
+ let stepTmpHandle = null;
4696
5182
  const exposedEnvVars = /* @__PURE__ */ new Set();
4697
5183
  let mountCounter = 0;
4698
5184
  const env = process.env;
4699
5185
  async function ensureTmpdir() {
4700
- if (stepTmpdir === null) stepTmpdir = await fsPromises.mkdtemp(join(tmpdir(), "kici-secret-files-"));
4701
- return stepTmpdir;
5186
+ if (stepTmpHandle === null) stepTmpHandle = await makeTempDir("secret-files");
5187
+ return stepTmpHandle.path;
4702
5188
  }
4703
5189
  return createStepSecrets(mergedFlat, env, request.secretMeta, {
4704
5190
  host: {
@@ -4722,16 +5208,14 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
4722
5208
  delete process.env[envVar];
4723
5209
  }
4724
5210
  exposedEnvVars.clear();
4725
- if (stepTmpdir !== null) {
4726
- await fsPromises.rm(stepTmpdir, {
4727
- recursive: true,
4728
- force: true
4729
- });
4730
- stepTmpdir = null;
5211
+ if (stepTmpHandle !== null) {
5212
+ await stepTmpHandle.cleanup();
5213
+ stepTmpHandle = null;
4731
5214
  }
4732
5215
  },
4733
5216
  onDisposeError: (err) => {
4734
- 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`);
4735
5219
  }
4736
5220
  });
4737
5221
  }
@@ -4755,7 +5239,12 @@ function createSecretMasker(request) {
4755
5239
  * before spawning this process). This is the single shell-construction code
4756
5240
  * path shared by step execution (`createSandboxStepContext`) and the per-job
4757
5241
  * init phase (`runInitPhase`), so init commands run through the identical shell
4758
- * 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.
4759
5248
  *
4760
5249
  * Intercept zx subprocess output via the log callback: zx does NOT write child
4761
5250
  * stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
@@ -4778,24 +5267,80 @@ function createSecretMasker(request) {
4778
5267
  function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
4779
5268
  return $({
4780
5269
  cwd,
4781
- env: { ...process.env },
5270
+ env: process.env,
4782
5271
  verbose: true,
4783
5272
  quiet: false,
4784
- log: makeStreamingZxLog((line) => maskedSendFn({
5273
+ log: makeStreamingZxLog((line, stream) => maskedSendFn({
4785
5274
  type: "log.line",
4786
5275
  stepIndex,
4787
- line
5276
+ line,
5277
+ stream
4788
5278
  }))
4789
5279
  });
4790
5280
  }
4791
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
+ /**
4792
5337
  * Create a StepContext natively inside the workflow runner.
4793
5338
  *
4794
5339
  * The context is reconstructed from the environment and IPC request fields --
4795
5340
  * NOT serialized across the process boundary. This means zx $ runs natively
4796
5341
  * inside this process with full shell access.
4797
5342
  */
4798
- 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) {
4799
5344
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
4800
5345
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
4801
5346
  const rawPayload = rawPayloadFromEvent(request.event);
@@ -4841,7 +5386,9 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4841
5386
  isTestRun: request.isTestRun ?? false,
4842
5387
  context: request.context,
4843
5388
  cache: createCacheApi(workDir, buildCacheTransport()),
4844
- emit: async (eventName, payload, options) => {
5389
+ artifacts: createArtifactsApi(workDir, buildArtifactTransport()),
5390
+ emit: async (nameOrDefinition, payload, options) => {
5391
+ const eventName = resolveEmitEventName(nameOrDefinition);
4845
5392
  const reqId = randomUUID();
4846
5393
  sendMessage({
4847
5394
  type: "event.emit",
@@ -4863,6 +5410,8 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4863
5410
  setSecretOutput: (key, value) => {
4864
5411
  secretOutputs.set(key, value);
4865
5412
  },
5413
+ mktemp: (label) => jobTempScope.mktemp(sanitizeTempLabel(label ?? stepName)),
5414
+ mktempFile: (label, opts) => jobTempScope.mktempFile(sanitizeTempLabel(label ?? stepName), opts),
4866
5415
  kici,
4867
5416
  attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
4868
5417
  ...rawPayload && { rawPayload },
@@ -5047,14 +5596,15 @@ async function applyOverlayIfRequested(request, workflowDir) {
5047
5596
  * Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
5048
5597
  * `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
5049
5598
  * on `process.env` here (before the step loop) means every step subprocess —
5050
- * 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
5051
5600
  * works in steps exactly as it does locally. We also register the dep-restore
5052
5601
  * scratch-dir exclude now that a real `.git` exists in the workspace.
5053
5602
  */
5054
5603
  async function makeOverlayGitUsable(request, workspaceDir) {
5055
5604
  if (!request.fullRepo) return;
5056
5605
  if (!existsSync(join(workspaceDir, ".git"))) return;
5057
- const cfgPath = join(await fsPromises.mkdtemp(join(tmpdir(), "kici-gitcfg-")), "config");
5606
+ const { path: cfgDir } = await makeTempDir("gitcfg");
5607
+ const cfgPath = join(cfgDir, "config");
5058
5608
  await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
5059
5609
  process.env.GIT_CONFIG_GLOBAL = cfgPath;
5060
5610
  trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
@@ -5245,7 +5795,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
5245
5795
  stepIndex: -1,
5246
5796
  line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
5247
5797
  });
5248
- 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);
5249
5799
  try {
5250
5800
  ack = await waitForConcurrencyAck(waitCapMs);
5251
5801
  } catch (waitErr) {
@@ -5434,6 +5984,46 @@ async function runCancelPathHooks(args) {
5434
5984
  };
5435
5985
  }
5436
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
+ /**
5437
6027
  * Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
5438
6028
  * factory when `dynamicSource` is set, otherwise looking up the static job)
5439
6029
  * and normalise the result into the `Step[]` shape the step loop consumes.
@@ -5449,30 +6039,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5449
6039
  driftDroppedJobs = dynamicResult.droppedJobs;
5450
6040
  if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
5451
6041
  } else rawSteps = extractSteps(workflow, request.jobName);
5452
- const refMap = /* @__PURE__ */ new WeakMap();
5453
- let stepCounter = 0;
5454
- const coerce = (stepOrFn) => {
5455
- if (typeof stepOrFn === "function") {
5456
- stepCounter++;
5457
- const name = `step-${stepCounter}`;
5458
- refMap.set(stepOrFn, name);
5459
- return {
5460
- _tag: "Step",
5461
- name,
5462
- run: stepOrFn,
5463
- outputs: void 0
5464
- };
5465
- }
5466
- const s = stepOrFn;
5467
- if (!s.name) {
5468
- stepCounter++;
5469
- return {
5470
- ...s,
5471
- name: `step-${stepCounter}`
5472
- };
5473
- }
5474
- return s;
6042
+ const coerceState = {
6043
+ counter: 0,
6044
+ refMap: /* @__PURE__ */ new WeakMap(),
6045
+ autoNamed: /* @__PURE__ */ new WeakSet()
5475
6046
  };
6047
+ const refMap = coerceState.refMap;
6048
+ const coerce = (stepOrFn) => coerceStep(stepOrFn, coerceState);
5476
6049
  const normalizedSteps = [];
5477
6050
  const nodes = [];
5478
6051
  let flatIndex = 0;
@@ -5519,9 +6092,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5519
6092
  * Phase 6 — Build the output infrastructure: the per-step operator-secret
5520
6093
  * key set (used for setEnv override protection), and the output / job-output
5521
6094
  * maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
5522
- * 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.
5523
6100
  */
5524
- function buildOutputInfrastructure(request, refMap) {
6101
+ function buildOutputInfrastructure(request, refMap, workflowSdkSetters) {
5525
6102
  const operatorSecretKeys = /* @__PURE__ */ new Set();
5526
6103
  if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
5527
6104
  if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
@@ -5529,9 +6106,12 @@ function buildOutputInfrastructure(request, refMap) {
5529
6106
  const secretOutputs = /* @__PURE__ */ new Map();
5530
6107
  setStepOutputsMap(outputsMap);
5531
6108
  setStepRefMap(refMap);
6109
+ workflowSdkSetters?.setStepOutputsMap(outputsMap);
6110
+ workflowSdkSetters?.setStepRefMap(refMap);
5532
6111
  const jobOutputsMap = /* @__PURE__ */ new Map();
5533
6112
  if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
5534
6113
  setJobOutputsMap(jobOutputsMap);
6114
+ workflowSdkSetters?.setJobOutputsMap(jobOutputsMap);
5535
6115
  return {
5536
6116
  operatorSecretKeys,
5537
6117
  outputsMap,
@@ -5540,27 +6120,82 @@ function buildOutputInfrastructure(request, refMap) {
5540
6120
  };
5541
6121
  }
5542
6122
  /**
5543
- * Phase 7 Evaluate job-level rules. When any rule fails, send a
5544
- * `job.complete{success}` with all steps marked skipped and exit 0.
5545
- * 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'`.
5546
6128
  */
5547
- async function maybeSkipJobOnRules(job, request, normalizedSteps) {
5548
- if (!job?.rules || job.rules.length === 0) return false;
5549
- const ruleCtx = createRuleContext(request.event ?? {}, [], process.env, request.dispatchInputs ?? {}, deriveFanout(request));
5550
- 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;
5551
6158
  const skippedResults = normalizedSteps.map((s, i) => ({
5552
6159
  name: s.name,
5553
6160
  stepIndex: i,
5554
6161
  status: ExecutionStepStatus.enum.skipped,
5555
6162
  durationMs: 0
5556
6163
  }));
5557
- flushOutputCapture();
5558
- capturePrepareActive = false;
5559
- 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 {
5560
6171
  type: "job.complete",
5561
6172
  status: ExecutionJobStatus.enum.success,
5562
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)
5563
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);
5564
6199
  process.exit(0);
5565
6200
  }
5566
6201
  /**
@@ -5610,8 +6245,8 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
5610
6245
  * Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
5611
6246
  * pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
5612
6247
  * step's pair and points the runner's process.env at it (each step's zx $
5613
- * snapshots process.env at context creation, which happens AFTER this
5614
- * 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
5615
6250
  * re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
5616
6251
  * step's delta and releases the pair.
5617
6252
  *
@@ -5684,12 +6319,13 @@ async function runInitPhaseOrFailJob(args) {
5684
6319
  })
5685
6320
  });
5686
6321
  if (initSpecs.length === 0) return;
6322
+ const initCache = createCacheApi(stepCwd, buildCacheTransport());
5687
6323
  const initResult = await runInitPhase({
5688
6324
  specs: initSpecs,
5689
6325
  shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
5690
6326
  sendIpc: maskedSend,
5691
6327
  stepIndexBase: INIT_STEP_INDEX_BASE,
5692
- cache: createCacheApi(stepCwd, buildCacheTransport()),
6328
+ cache: initCache,
5693
6329
  env: {
5694
6330
  beginCapture: async () => {
5695
6331
  process.env.KICI_ENV = envFiles.envFile;
@@ -5762,7 +6398,8 @@ async function main() {
5762
6398
  await installDependenciesIfNeeded(workflowDir, request);
5763
6399
  if (aborted) abortAndExit("aborted after deps");
5764
6400
  await restoreSourceTarballIfRequested(workflowDir, request);
5765
- const module = (await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend)).module;
6401
+ const loaded = await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend);
6402
+ const module = loaded.module;
5766
6403
  const workflow = extractWorkflow(module, request.workflowName);
5767
6404
  await evaluateConcurrencyGroupIfPresent(workflow, request);
5768
6405
  const apiTransport = async (method, params) => {
@@ -5776,8 +6413,11 @@ async function main() {
5776
6413
  return waitForApiResponse(reqId);
5777
6414
  };
5778
6415
  const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5779
- const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
6416
+ const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
5780
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);
5781
6421
  await maybeSkipJobOnRules(job, request, normalizedSteps);
5782
6422
  if (aborted) abortAndExit("aborted after rules");
5783
6423
  const jobHooks = collectJobHooks(job);
@@ -5795,6 +6435,7 @@ async function main() {
5795
6435
  const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
5796
6436
  const stepTasks = new StepTaskRegistry();
5797
6437
  const stepAbortControllers = /* @__PURE__ */ new Map();
6438
+ const jobTempScope = createTempScope();
5798
6439
  const createStepCtxWithCapture = (stepIndex, stepName) => {
5799
6440
  const stepAbort = new AbortController();
5800
6441
  stepAbortControllers.set(stepIndex, stepAbort);
@@ -5808,7 +6449,7 @@ async function main() {
5808
6449
  secrets: handle.secrets,
5809
6450
  dispose: handle.dispose
5810
6451
  });
5811
- 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);
5812
6453
  if (globalRepoInfo) {
5813
6454
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
5814
6455
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
@@ -5853,7 +6494,7 @@ async function main() {
5853
6494
  });
5854
6495
  jobDeadline.clear();
5855
6496
  await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
5856
- let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
6497
+ let finalStatus = initialJobStatus(loopResult.status);
5857
6498
  let cancelFailureReason;
5858
6499
  if (aborted) {
5859
6500
  const cancelResult = await runCancelPathHooks({
@@ -5874,6 +6515,7 @@ async function main() {
5874
6515
  cancelFailureReason = cancelResult.cancelFailureReason;
5875
6516
  }
5876
6517
  if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
6518
+ await drainJobTempScopeToLog(jobTempScope, maskedSend);
5877
6519
  emitJobComplete({
5878
6520
  finalStatus,
5879
6521
  loopResult,
@@ -5933,6 +6575,6 @@ main().catch((error) => {
5933
6575
  setTimeout(() => process.exit(1), 100);
5934
6576
  });
5935
6577
  //#endregion
5936
- export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
6578
+ export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
5937
6579
 
5938
6580
  //# sourceMappingURL=workflow-runner.js.map