@kici-dev/agent 0.1.14 → 0.1.16

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.
@@ -1,1060 +1,1735 @@
1
- import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
2
- import { dirname as __cjs_dirname } from "node:path";
3
- __cjs_dirname(__cjs_fileURLToPath(import.meta.url));
4
1
  import { register } from "node:module";
5
2
  import { createInterface } from "node:readline";
6
3
  import crypto, { createHash, randomUUID } from "node:crypto";
7
4
  import { existsSync } from "node:fs";
8
- import fsPromises, { access, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
9
- import os, { tmpdir } from "node:os";
10
- import path, { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import fsPromises, { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
6
+ import os, { homedir, tmpdir } from "node:os";
7
+ import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
8
  import { $ } from "zx";
12
9
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
13
- import { ExecutionJobStatus, ExecutionStepStatus } from "@kici-dev/engine";
14
- import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
15
- import { execFile } from "node:child_process";
10
+ import { CacheOutcome, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
11
+ import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeCacheSpecs, normalizeRequireApproval, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
16
12
  import { Readable, Transform } from "node:stream";
17
13
  import { pipeline } from "node:stream/promises";
18
14
  import { createGunzip } from "node:zlib";
15
+ import { c, x } from "tar";
16
+ import https from "node:https";
17
+ import http from "node:http";
19
18
  import { fileURLToPath, pathToFileURL } from "node:url";
20
- import { x } from "tar";
19
+ import { execFile } from "node:child_process";
21
20
  import { promisify } from "node:util";
22
21
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
23
- import https from "node:https";
24
- import http from "node:http";
22
+ var __defProp = Object.defineProperty;
23
+ var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
24
+ var __exportAll = (all, no_symbols) => {
25
+ let target = {};
26
+ for (var name in all) __defProp(target, name, {
27
+ get: all[name],
28
+ enumerable: true
29
+ });
30
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
31
+ return target;
32
+ };
25
33
  import.meta.url;
26
34
  //#endregion
27
- //#region src/execution/sandbox/log-masker.ts
35
+ //#region src/execution/dep-restore.ts
28
36
  /**
29
- * Secret value masking for log lines.
37
+ * Dependency restoration from cached tarballs.
30
38
  *
31
- * Replaces all occurrences of registered secret values with '***' in log output.
32
- * Used by the workflow runner to prevent secret leaks in IPC log messages.
39
+ * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
40
+ * and extracts to .kici/node_modules/ in the work directory.
33
41
  *
34
- * Performance: Builds a single combined regex from all secret values, so each
35
- * log line is scanned in a single pass (not O(secrets * lines)).
42
+ * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
43
+ * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
44
+ * file:// URLs use a buffer-based approach (local, no streaming benefit).
45
+ *
46
+ * Streaming downloads have a 5-minute timeout and up to 2 retries.
36
47
  */
37
- /** Minimum length for a secret value to be maskable (avoids false positives). */
38
- const MIN_MASK_LENGTH = 3;
39
48
  /**
40
- * Escape regex special characters in a string.
49
+ * Compute SHA-256 hash of a buffer.
41
50
  */
42
- function escapeRegExp(s) {
43
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ function computeHash(data) {
52
+ return sha256(data);
44
53
  }
45
54
  /**
46
- * Masks secret values in log lines.
47
- *
48
- * Usage:
49
- * ```ts
50
- * const masker = new LogMasker();
51
- * masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
52
- * masker.mask('Token is abc123'); // 'Token is ***'
53
- * // 'ab' is NOT masked (< 3 chars)
54
- * ```
55
+ * Extract a gzip tarball from a buffer into the target directory.
56
+ * Used for file:// URLs where streaming provides no benefit.
55
57
  */
56
- var LogMasker = class {
57
- pattern = null;
58
- /**
59
- * Register secret values to be masked in log output.
60
- *
61
- * Values shorter than 3 characters are skipped to avoid false positives.
62
- * Base64-encoded variants of each qualifying secret are also registered,
63
- * preventing leaks when secrets appear base64-encoded in logs (e.g.,
64
- * Authorization: Basic headers, base64-encoded config values).
65
- * Values are sorted by length descending so longer values are matched first
66
- * (prevents partial masking when one secret is a substring of another).
67
- */
68
- registerSecrets(secrets) {
69
- const seen = /* @__PURE__ */ new Set();
70
- const values = [];
71
- for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
72
- seen.add(value);
73
- values.push(value);
74
- const b64 = Buffer.from(value).toString("base64");
75
- if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
76
- seen.add(b64);
77
- values.push(b64);
78
- }
79
- }
80
- if (values.length === 0) {
81
- this.pattern = null;
82
- return;
83
- }
84
- values.sort((a, b) => b.length - a.length);
85
- this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
86
- }
87
- /**
88
- * Mask all registered secret values in a log line.
89
- *
90
- * Returns the line unchanged if no secrets are registered.
91
- */
92
- mask(line) {
93
- if (!this.pattern) return line;
94
- this.pattern.lastIndex = 0;
95
- return line.replace(this.pattern, "***");
96
- }
97
- /**
98
- * Returns true if any maskable secrets are registered.
99
- */
100
- hasSecrets() {
101
- return this.pattern !== null;
102
- }
103
- };
104
- //#endregion
105
- //#region src/execution/sandbox/secret-merge.ts
58
+ async function extractTarball(data, targetDir) {
59
+ await mkdir(targetDir, { recursive: true });
60
+ const readable = Readable.from(data);
61
+ await new Promise((resolve, reject) => {
62
+ readable.pipe(x({
63
+ cwd: targetDir,
64
+ gzip: true
65
+ })).on("finish", resolve).on("error", reject);
66
+ });
67
+ }
106
68
  /**
107
- * Secret merging utilities for the workflow runner.
69
+ * Stream download and extract an HTTP/HTTPS tarball.
108
70
  *
109
- * Separated from workflow-runner.ts to allow unit testing without
110
- * triggering the runner's top-level side effects (process handlers, main()).
71
+ * Computes SHA-256 hash on the fly via a Transform stream.
72
+ * Returns the computed hash of the compressed tarball data.
111
73
  */
74
+ async function streamFetchAndExtract(url, targetDir) {
75
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$2) });
76
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
77
+ if (!response.body) throw new Error("No response body");
78
+ const nodeStream = Readable.fromWeb(response.body);
79
+ const hash = createHash("sha256");
80
+ const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
81
+ hash.update(chunk);
82
+ callback(null, chunk);
83
+ } });
84
+ await mkdir(targetDir, { recursive: true });
85
+ await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
86
+ return hash.digest("hex");
87
+ }
112
88
  /**
113
- * Merge orchestrator-level secrets with auto-flattened context keys.
89
+ * Extract the dep tarball into a per-attempt scratch dir so retries never race
90
+ * with still-draining I/O from a previous failed attempt.
114
91
  *
115
- * Precedence (last wins):
116
- * 1. Orchestrator-level secrets (lowest)
117
- * 2. Context-flattened keys in declaration order (each context's keys overlay previous)
92
+ * When `pipeline()` rejects on a network error or AbortSignal timeout, the
93
+ * underlying `tar.x` continues flushing pending file writes for an unbounded
94
+ * window after the promise settles `pipeline` does not block on async
95
+ * filesystem side effects. If the next retry then runs `rm -rf` on the same
96
+ * `node_modules/`, the walk races with those writes and `rmdir` fails with
97
+ * ENOTEMPTY (new files keep appearing under a directory we just emptied).
118
98
  *
119
- * This means: context-flattened keys override orchestrator-level secrets,
120
- * and for collisions between contexts, last declared context wins.
121
- */
122
- function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
123
- const merged = { ...orchestratorSecrets };
124
- for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
125
- return merged;
126
- }
127
- //#endregion
128
- //#region src/execution/hook-executor.ts
129
- /** Default hook timeout: 5 minutes */
130
- const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
131
- /**
132
- * Build outcome metadata from execution state.
99
+ * We sidestep the race entirely by extracting each attempt into a unique
100
+ * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
101
+ * draining writes are harmless — the next attempt does not touch them. On
102
+ * success `moveScratchIntoRepo` renames the extracted entries into place
103
+ * (atomic on the same filesystem), then best-effort cleans the scratch dir.
133
104
  *
134
- * Duration is calculated as elapsed time since startTime.
105
+ * Scratch dirs land inside the customer's cloned working tree, so the clone
106
+ * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
107
+ * keep them out of `git status` for any workflow step that shells out to git.
108
+ * See `excludeScratchFromGit`.
135
109
  */
136
- function buildOutcomeMetadata(opts) {
110
+ async function extractIntoScratch(url, kiciDir, attempt) {
111
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
112
+ await mkdir(scratchDir, { recursive: true });
137
113
  return {
138
- status: opts.status,
139
- reason: opts.reason,
140
- failedStep: opts.failedStep,
141
- stepOutputs: opts.stepOutputs,
142
- duration: Date.now() - opts.startTime
114
+ scratchDir,
115
+ hash: await streamFetchAndExtract(url, scratchDir)
143
116
  };
144
117
  }
145
118
  /**
146
- * Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
119
+ * Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
120
+ * so any in-flight or orphaned dep-restore scratch dirs are invisible to
121
+ * `git status` / `git add` inside the customer's cloned working tree.
122
+ *
123
+ * Why `.git/info/exclude` and not `.gitignore`:
124
+ * - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
125
+ * modify it. Doing so would surface the rule in their PRs and create a
126
+ * diff customers never asked for.
127
+ * - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
128
+ * mechanism for "ignore these patterns in THIS working tree". Git creates
129
+ * an empty (template-commented) file on `git init` / `git clone`, so it
130
+ * already exists by the time we're called.
131
+ *
132
+ * Why this lives next to `extractIntoScratch`:
133
+ * - The exclude glob is tied 1:1 to the scratch dir naming convention. If
134
+ * the prefix ever changes, the rule must change too. Defining both in the
135
+ * same file means a rename touches one place, not two.
136
+ *
137
+ * Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
138
+ * `git clone` and the dir layout differs) we log and continue — failing the
139
+ * job over a missing git ignore wiring would be worse than the cosmetic
140
+ * issue we're solving.
141
+ *
142
+ * Idempotent: callers may invoke this multiple times (dual-clone path, retry
143
+ * after partial setup). We skip the append if the glob is already present.
144
+ *
145
+ * @param repoWorkDir - The git working tree root (the dir that contains
146
+ * `.git/`). For normal workflows this is the agent's job workDir; for
147
+ * global workflows it is the workflow repo dir (whose `.kici/` carries
148
+ * the scratch dirs).
147
149
  */
148
- function normalizeHook(hook, hookType) {
149
- if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
150
- if (typeof hook === "function") return {
151
- name: hookType,
152
- type: hookType,
153
- run: hook
154
- };
155
- if (typeof hook === "object" && "run" in hook) return {
156
- name: hookType,
157
- type: hookType,
158
- run: hook.run,
159
- timeout: hook.timeout
160
- };
161
- throw new Error(`Invalid hook input for ${hookType}`);
150
+ async function excludeScratchFromGit(repoWorkDir) {
151
+ const excludePath = join(repoWorkDir, ".git", "info", "exclude");
152
+ try {
153
+ const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
154
+ if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
155
+ const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
156
+ await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
157
+ } catch (err) {
158
+ logger$4.warn("Failed to register scratch dir glob in .git/info/exclude", {
159
+ excludePath,
160
+ error: err instanceof Error ? err.message : String(err)
161
+ });
162
+ }
162
163
  }
163
164
  /**
164
- * Execute a single hook with timeout enforcement and IPC reporting.
165
+ * Rewrite localhost URLs to use the orchestrator host.
165
166
  *
166
- * Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
167
- * The hook runs in the same sandbox context as regular steps.
167
+ * The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
168
+ * but agent containers can't reach localhost. This utility replaces the
169
+ * host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
168
170
  */
169
- async function executeHook(opts) {
170
- const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
171
- const normalized = normalizeHook(opts.hook, hookType);
172
- const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
173
- sendIpc({
174
- type: "step.start",
175
- stepIndex,
176
- stepName: normalized.name,
177
- step_type: `hook:${hookType}`
178
- });
179
- const startTime = Date.now();
180
- const mergedCtx = {
181
- ...stepContext,
182
- outcome
183
- };
184
- const abortController = new AbortController();
185
- const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
171
+ function resolveOrchestratorUrl(url) {
172
+ if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
173
+ const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
174
+ if (!orchestratorUrl) return url;
186
175
  try {
187
- await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
188
- abortController.signal.addEventListener("abort", () => {
189
- reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
190
- });
191
- })]);
192
- clearTimeout(timeoutId);
193
- sendIpc({
194
- type: "step.complete",
195
- stepIndex,
196
- status: "success",
197
- durationMs: Date.now() - startTime,
198
- step_type: `hook:${hookType}`
176
+ const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
177
+ const parsed = new URL(url);
178
+ parsed.hostname = orchestratorParsed.hostname;
179
+ return parsed.toString();
180
+ } catch {
181
+ return url;
182
+ }
183
+ }
184
+ /**
185
+ * Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
186
+ * packed repo-root-relative, so the scratch holds repo-root entries:
187
+ * `.kici/node_modules` for every manager, plus (for pnpm) the root
188
+ * `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
189
+ * already exists in the work tree (cloned or source-restored), so its children
190
+ * are moved individually; every other top-level entry is moved wholesale.
191
+ *
192
+ * On a cache-hit execution agent the destinations do not pre-exist (source
193
+ * restore excludes node_modules and never carries sibling dirs), so the renames
194
+ * have nothing to race; the defensive `rm` covers re-runs.
195
+ */
196
+ async function moveScratchIntoRepo(scratchDir, workDir) {
197
+ for (const child of await fsPromises.readdir(scratchDir)) if (child === ".kici") {
198
+ const kiciScratch = join(scratchDir, ".kici");
199
+ for (const sub of await fsPromises.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
200
+ } else await moveInto(join(scratchDir, child), join(workDir, child));
201
+ }
202
+ /** Move `src` to `dest`, creating the parent and clearing any stale dest. */
203
+ async function moveInto(src, dest) {
204
+ await mkdir(dirname(dest), { recursive: true });
205
+ await fsPromises.rm(dest, {
206
+ recursive: true,
207
+ force: true
208
+ });
209
+ await fsPromises.rename(src, dest);
210
+ }
211
+ /** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
212
+ async function cleanupScratch(scratchDir) {
213
+ try {
214
+ await fsPromises.rm(scratchDir, {
215
+ recursive: true,
216
+ force: true
199
217
  });
200
- return { success: true };
201
- } catch (e) {
202
- clearTimeout(timeoutId);
203
- const durationMs = Date.now() - startTime;
204
- const error = toErrorMessage(e);
205
- sendIpc({
206
- type: "step.complete",
207
- stepIndex,
208
- status: "failed",
209
- durationMs,
210
- error: { message: error },
211
- step_type: `hook:${hookType}`
218
+ } catch (cleanupErr) {
219
+ logger$4.warn("Scratch dir cleanup failed (orphan left behind)", {
220
+ scratchDir,
221
+ error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
212
222
  });
213
- return {
214
- success: false,
215
- error
216
- };
217
223
  }
218
224
  }
225
+ /**
226
+ * Restore dependencies from a cached tarball into the cloned repo.
227
+ *
228
+ * The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
229
+ * carries `.kici/node_modules`; pnpm additionally carries the root
230
+ * `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
231
+ * Restore extracts into a scratch dir, then moves each entry into place — one
232
+ * code path for all managers.
233
+ *
234
+ * For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
235
+ * with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
236
+ * For file:// URLs: a buffer-based approach (local, no streaming benefit).
237
+ *
238
+ * @param workDir - Root directory of the cloned repository
239
+ * @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
240
+ * @param depsHash - Optional expected SHA-256 hash of the tarball
241
+ */
242
+ async function restoreDeps(workDir, depsUrl, depsHash) {
243
+ depsUrl = resolveOrchestratorUrl(depsUrl);
244
+ logger$4.info("Downloading dependency tarball", { url: depsUrl });
245
+ const kiciDir = join(workDir, ".kici");
246
+ if (depsUrl.startsWith("file://")) {
247
+ const localPath = fileURLToPath(depsUrl);
248
+ const data = await fsPromises.readFile(localPath);
249
+ if (depsHash) {
250
+ const actualHash = computeHash(data);
251
+ if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
252
+ }
253
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
254
+ await extractTarball(data, scratchDir);
255
+ await moveScratchIntoRepo(scratchDir, workDir);
256
+ await cleanupScratch(scratchDir);
257
+ const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
258
+ logger$4.info("Dependencies restored from cache (file)", {
259
+ sizeMB,
260
+ targetDir: workDir
261
+ });
262
+ return;
263
+ }
264
+ if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
265
+ let lastError;
266
+ for (let attempt = 0; attempt <= 2; attempt++) {
267
+ if (attempt > 0) logger$4.warn("Retrying dep tarball download", {
268
+ attempt,
269
+ url: depsUrl
270
+ });
271
+ try {
272
+ const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
273
+ if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
274
+ await moveScratchIntoRepo(scratchDir, workDir);
275
+ await cleanupScratch(scratchDir);
276
+ logger$4.info("Dependencies restored from cache (stream)", { targetDir: workDir });
277
+ return;
278
+ } catch (err) {
279
+ lastError = err instanceof Error ? err : new Error(String(err));
280
+ logger$4.warn("Dep tarball download failed", {
281
+ attempt,
282
+ error: lastError.message
283
+ });
284
+ }
285
+ }
286
+ throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
287
+ }
288
+ var logger$4, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
289
+ var init_dep_restore = __esmMin((() => {
290
+ logger$4 = createLogger({ prefix: "dep-restore" });
291
+ DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
292
+ SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
293
+ SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
294
+ }));
219
295
  //#endregion
220
- //#region src/execution/rule-evaluator.ts
221
- initZx();
296
+ //#region src/execution/download.ts
222
297
  /**
223
- * Create RuleContext for agent-side rule evaluation.
298
+ * Shared HTTP/HTTPS download utility.
224
299
  *
225
- * @param event - Event payload from the dispatch message
226
- * @param changedFiles - List of files changed in this event
227
- * @param env - Merged environment variables
300
+ * Extracted from workflow-loader.ts to avoid duplication across
301
+ * dep-restore.ts and workflow-loader.ts.
228
302
  */
229
- function createRuleContext(event, changedFiles = [], env = {}) {
230
- return {
231
- event,
232
- changedFiles,
233
- env,
234
- $
235
- };
303
+ var download_exports = /* @__PURE__ */ __exportAll({
304
+ downloadUrl: () => downloadUrl,
305
+ uploadToPresignedUrl: () => uploadToPresignedUrl
306
+ });
307
+ /**
308
+ * Download content from an HTTP/HTTPS URL.
309
+ *
310
+ * Includes a 5-minute timeout to prevent the agent from hanging indefinitely
311
+ * on slow or unresponsive endpoints.
312
+ *
313
+ * @param url - The URL to download from
314
+ * @returns The response body as a Buffer
315
+ */
316
+ function downloadUrl(url) {
317
+ return new Promise((resolve, reject) => {
318
+ (url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) }, (res) => {
319
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
320
+ reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
321
+ res.resume();
322
+ return;
323
+ }
324
+ const chunks = [];
325
+ res.on("data", (chunk) => chunks.push(chunk));
326
+ res.on("end", () => resolve(Buffer.concat(chunks)));
327
+ res.on("error", reject);
328
+ }).on("error", reject);
329
+ });
330
+ }
331
+ /**
332
+ * Upload a buffer to a pre-signed S3 URL via HTTP PUT.
333
+ *
334
+ * Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
335
+ * 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
336
+ * filesystem cache backend's signed URLs work from container agents that
337
+ * can't reach the orchestrator's host loopback directly.
338
+ *
339
+ * @param url - The pre-signed URL to upload to
340
+ * @param data - The buffer to upload
341
+ */
342
+ function uploadToPresignedUrl(url, data) {
343
+ return new Promise((resolve, reject) => {
344
+ const resolved = resolveOrchestratorUrl(url);
345
+ const parsed = new URL(resolved);
346
+ const req = (parsed.protocol === "https:" ? https : http).request({
347
+ hostname: parsed.hostname,
348
+ port: parsed.port,
349
+ path: parsed.pathname + parsed.search,
350
+ method: "PUT",
351
+ headers: { "Content-Length": data.length }
352
+ }, (res) => {
353
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
354
+ reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
355
+ res.resume();
356
+ return;
357
+ }
358
+ res.resume();
359
+ res.on("end", () => resolve());
360
+ res.on("error", reject);
361
+ });
362
+ req.on("error", reject);
363
+ req.end(data);
364
+ });
236
365
  }
366
+ var DOWNLOAD_TIMEOUT_MS$1;
367
+ var init_download = __esmMin((() => {
368
+ init_dep_restore();
369
+ DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
370
+ }));
237
371
  //#endregion
238
- //#region src/execution/sandbox/step-loop.ts
372
+ //#region src/execution/cache/cache-engine.ts
239
373
  /**
240
- * Execute a single step with timeout enforcement.
374
+ * User-facing cache engine (sandbox-side).
241
375
  *
242
- * Timeout pattern using Promise.race + AbortController, with IPC status reporting.
376
+ * Packs `CacheSpec.paths` into a gzip tarball (mirrors dep-packer's tar+sha256
377
+ * approach) and restores a tarball with on-the-fly SHA-256 verification
378
+ * (mirrors dep-restore's streaming pipeline). Drives the orchestrator over an
379
+ * injected request-response transport (IPC -> agent WS -> orchestrator).
380
+ *
381
+ * Path safety: each path is either `~`-prefixed (home-relative) or
382
+ * repo-root-relative; absolute paths and `..` escapes are rejected so a
383
+ * workflow cannot exfiltrate or clobber files outside its tree/home.
384
+ *
385
+ * Multi-root layout: a spec may mix repo-relative and home-relative paths.
386
+ * Each entry is staged under an anchor prefix — `__repo__/<rel>` for
387
+ * repo-root-relative entries, `__home__/<rel>` for `~`-prefixed entries — so a
388
+ * single tarball can carry both roots and extract restores each group to the
389
+ * right destination (repo entries under `workDir`, home entries under the
390
+ * homedir). Extraction lands in a scratch dir first, then moves each group
391
+ * into place so a partial restore never leaves half-written paths in the live
392
+ * tree (mirrors dep-restore).
243
393
  */
244
- async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords) {
245
- sendFn({
246
- type: "step.start",
247
- stepIndex,
248
- stepName: step.name
394
+ const logger$3 = createLogger({ prefix: "cache-engine" });
395
+ /** Download timeout for a presigned cache GET: 5 minutes. */
396
+ const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
397
+ /** Anchor prefix for repo-root-relative cache entries inside the tar. */
398
+ const REPO_ANCHOR = "__repo__";
399
+ /** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
400
+ const HOME_ANCHOR = "__home__";
401
+ /**
402
+ * Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
403
+ * Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
404
+ * files outside its tree / home.
405
+ */
406
+ function resolveCachePath(workDir, p, roots) {
407
+ const home = roots?.home ?? homedir();
408
+ if (p === "~" || p.startsWith("~/")) {
409
+ const rel = p === "~" ? "" : p.slice(2);
410
+ return rel ? join(home, rel) : home;
411
+ }
412
+ if (isAbsolute(p)) throw new Error(`cache path must be repo-relative or ~-prefixed: ${p}`);
413
+ const resolved = resolve(workDir, p);
414
+ const rel = relative(workDir, resolved);
415
+ if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error(`cache path escapes the repo root: ${p}`);
416
+ return resolved;
417
+ }
418
+ /** Resolve + anchor every spec path; rejects escapes via resolveCachePath. */
419
+ function anchorEntries(workDir, paths, roots) {
420
+ const home = roots?.home ?? homedir();
421
+ return paths.map((p) => {
422
+ const abs = resolveCachePath(workDir, p, roots);
423
+ const isHome = p === "~" || p.startsWith("~/");
424
+ return {
425
+ abs,
426
+ anchor: isHome ? HOME_ANCHOR : REPO_ANCHOR,
427
+ rel: relative(isHome ? home : workDir, abs)
428
+ };
249
429
  });
250
- const startTime = Date.now();
251
- const abortController = new AbortController();
252
- const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
430
+ }
431
+ /**
432
+ * Pack the spec's paths into a gzip tarball + its SHA-256.
433
+ *
434
+ * Each path is copied into a staging dir under its anchor prefix
435
+ * (`__repo__/<rel>` or `__home__/<rel>`), the staging dir is tarred (portable
436
+ * mode strips uid/gid/mtime), and the staging dir is removed. The resulting
437
+ * tarball self-describes which root each entry restores to.
438
+ */
439
+ async function packCachePaths(workDir, paths, roots) {
440
+ const entries = anchorEntries(workDir, paths, roots);
441
+ const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
253
442
  try {
254
- const result = await Promise.race([step.run(ctx), new Promise((_, reject) => {
255
- abortController.signal.addEventListener("abort", () => {
256
- reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
443
+ const topLevel = /* @__PURE__ */ new Set();
444
+ for (const e of entries) {
445
+ const dest = join(staging, e.anchor, e.rel);
446
+ await mkdir(dirname(dest), { recursive: true });
447
+ await cp(e.abs, dest, {
448
+ recursive: true,
449
+ verbatimSymlinks: true
257
450
  });
258
- })]);
259
- clearTimeout(timeoutId);
260
- const durationMs = Date.now() - startTime;
261
- const outputsPayload = result != null ? result : void 0;
262
- if (outputsPayload) outputsMap.set(step.name, outputsPayload);
263
- const secretsAccessed = getSecretsAccessLog?.();
264
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
265
- sendFn({
266
- type: "step.complete",
267
- stepIndex,
268
- status: ExecutionStepStatus.enum.success,
269
- durationMs,
270
- ...outputsPayload && { outputs: outputsPayload },
271
- ...secretsAccessed !== void 0 && { secretsAccessed }
451
+ topLevel.add(e.anchor);
452
+ }
453
+ const stream = c({
454
+ gzip: true,
455
+ portable: true,
456
+ cwd: staging
457
+ }, [...topLevel]);
458
+ const chunks = [];
459
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
460
+ const tarball = Buffer.concat(chunks);
461
+ const hash = sha256(tarball);
462
+ logger$3.info("packed user cache", {
463
+ sizeBytes: tarball.length,
464
+ hash: hash.slice(0, 12),
465
+ paths
272
466
  });
273
467
  return {
274
- name: step.name,
275
- stepIndex,
276
- status: ExecutionStepStatus.enum.success,
277
- durationMs,
278
- ...outputsPayload && { outputs: outputsPayload }
468
+ tarball,
469
+ hash
279
470
  };
280
- } catch (e) {
281
- clearTimeout(timeoutId);
282
- const durationMs = Date.now() - startTime;
283
- const error = e instanceof Error ? e : new Error(String(e));
284
- const exitCode = extractExitCode(e);
285
- const signal = extractSignal(e);
286
- const secretsAccessed = getSecretsAccessLog?.();
287
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
288
- sendFn({
289
- type: "step.complete",
290
- stepIndex,
291
- status: ExecutionStepStatus.enum.failed,
292
- durationMs,
293
- error: {
294
- message: error.message,
295
- ...exitCode !== void 0 && { exitCode },
296
- ...signal !== void 0 && { signal }
297
- },
298
- ...secretsAccessed !== void 0 && { secretsAccessed }
471
+ } finally {
472
+ await rm(staging, {
473
+ recursive: true,
474
+ force: true
299
475
  });
300
- return {
301
- name: step.name,
302
- stepIndex,
303
- status: ExecutionStepStatus.enum.failed,
304
- durationMs,
305
- error: {
306
- message: error.message,
307
- ...exitCode !== void 0 && { exitCode },
308
- ...signal !== void 0 && { signal }
309
- }
310
- };
311
476
  }
312
477
  }
313
- /**
314
- * Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
315
- * the step performed. Called from both the success and failure paths so the
316
- * orchestrator's audit trail records every mount regardless of step outcome.
317
- */
318
- function emitSecretMountEvents(records, stepIndex, sendFn) {
319
- if (!records || records.length === 0) return;
320
- for (const record of records) sendFn({
321
- type: "step.secret_mount",
322
- stepIndex,
323
- sources: record.sources,
324
- target: record.target,
325
- kind: record.kind,
326
- ...record.envVar !== void 0 && { envVar: record.envVar }
327
- });
328
- }
329
- function extractExitCode(error) {
330
- if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
331
- }
332
- function extractSignal(error) {
333
- if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
478
+ /** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
479
+ async function moveAnchoredGroups(scratchDir, workDir, home) {
480
+ for (const anchor of await readdir(scratchDir)) {
481
+ const anchorDir = join(scratchDir, anchor);
482
+ const destRoot = anchor === HOME_ANCHOR ? home : anchor === REPO_ANCHOR ? workDir : null;
483
+ if (!destRoot) continue;
484
+ for (const child of await readdir(anchorDir)) {
485
+ const dest = join(destRoot, child);
486
+ await mkdir(dirname(dest), { recursive: true });
487
+ await rm(dest, {
488
+ recursive: true,
489
+ force: true
490
+ });
491
+ await rename(join(anchorDir, child), dest);
492
+ }
493
+ }
334
494
  }
335
495
  /**
336
- * Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
337
- * fails; returns null when the step should run normally.
496
+ * Stream-download a presigned URL, verify its SHA-256 on the fly (mirrors
497
+ * dep-restore's response -> hash -> gunzip -> tar pipeline), then move the
498
+ * anchored groups into place. Extracts into a scratch dir so a failed download
499
+ * never half-writes the live tree.
338
500
  */
339
- async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
340
- if (!step.rules || step.rules.length === 0) return null;
341
- const ruleCtx = createRuleContext(opts.event, [], opts.env);
342
- const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
343
- if (ruleResult.allPassed) return null;
344
- opts.sendIpc({
345
- type: "step.start",
346
- stepIndex,
347
- stepName: step.name
348
- });
349
- opts.sendIpc({
350
- type: "step.complete",
351
- stepIndex,
352
- status: ExecutionStepStatus.enum.failed,
353
- durationMs: 0
354
- });
355
- opts.sendIpc({
356
- type: "log.line",
357
- stepIndex,
358
- line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
359
- });
501
+ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
502
+ const home = roots?.home ?? homedir();
503
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
504
+ if (!response.ok || !response.body) throw new Error(`cache download HTTP ${response.status}`);
505
+ const hash = createHash("sha256");
506
+ const hashTransform = new Transform({ transform(chunk, _enc, cb) {
507
+ hash.update(chunk);
508
+ cb(null, chunk);
509
+ } });
510
+ await mkdir(workDir, { recursive: true });
511
+ const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
512
+ try {
513
+ await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
514
+ const digest = hash.digest("hex");
515
+ if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
516
+ await moveAnchoredGroups(scratch, workDir, home);
517
+ } finally {
518
+ await rm(scratch, {
519
+ recursive: true,
520
+ force: true
521
+ });
522
+ }
523
+ }
524
+ /** Build the imperative `ctx.cache` API bound to a workDir + transport. */
525
+ function createCacheApi(workDir, transport, roots) {
360
526
  return {
361
- name: step.name,
362
- stepIndex,
363
- status: ExecutionStepStatus.enum.skipped,
364
- durationMs: 0
527
+ async restore(spec) {
528
+ const r = await transport.restore(spec.key, spec.restoreKeys);
529
+ if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
530
+ await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
531
+ logger$3.info("user cache restored", {
532
+ key: spec.key,
533
+ matchedKey: r.matchedKey
534
+ });
535
+ return {
536
+ hit: true,
537
+ matchedKey: r.matchedKey
538
+ };
539
+ },
540
+ async save(spec) {
541
+ const begin = await transport.beginSave(spec.key);
542
+ if (begin.skip || !begin.uploadUrl) {
543
+ logger$3.info("user cache save skipped (key exists)", { key: spec.key });
544
+ return;
545
+ }
546
+ const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
547
+ const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
548
+ await uploadToPresignedUrl(begin.uploadUrl, tarball);
549
+ await transport.completeSave(spec.key, hash, tarball.length);
550
+ logger$3.info("user cache saved", {
551
+ key: spec.key,
552
+ sizeBytes: tarball.length
553
+ });
554
+ }
365
555
  };
366
556
  }
557
+ //#endregion
558
+ //#region src/execution/cache/cache-phase.ts
367
559
  /**
368
- * Run a single observer hook (beforeStep / afterStep). Failures only emit a
369
- * log line — they never change job status. Centralises the per-call boilerplate
370
- * so the per-step body can stay flat.
560
+ * Declarative cache phase (sandbox-side).
561
+ *
562
+ * Restores a list of {@link CacheSpec} before the work that depends on them
563
+ * (a job before its steps, or a step before its `run`) and saves them after
564
+ * (on an exact-key miss). Each operation surfaces as a `cache:restore` /
565
+ * `cache:save` pseudo-step — a `step.start` + `step.complete` IPC pair whose
566
+ * `step_type` comes from {@link CacheStepType} — exactly mirroring how hooks
567
+ * render as `hook:*` pseudo-steps. The `step.complete` `data` carries the
568
+ * {@link CacheOutcome} (plus key / matchedKey / bytes) so the agent can feed a
569
+ * `run.event` and the dashboard can render hit/miss/saved inline.
371
570
  */
372
- async function runObserverHook(args) {
373
- const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
374
- const outcome = buildOutcomeMetadata({
375
- status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
376
- stepOutputs: Object.fromEntries(opts.outputsMap),
377
- startTime: opts.startTime ?? Date.now(),
378
- ...failedStep !== void 0 && { failedStep }
379
- });
380
- const hookResult = await executeHook({
381
- hook,
382
- stepContext: opts.createStepContext(stepIndex, step.name),
383
- outcome,
384
- hookType,
385
- stepIndex: hookStepIndex,
386
- sendIpc: opts.sendIpc,
387
- timeout: 3e5
388
- });
389
- if (!hookResult.success) opts.sendIpc({
390
- type: "log.line",
391
- stepIndex,
392
- line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
393
- });
394
- }
395
571
  /**
396
- * Execute one iteration of the step loop: step rules → beforeStep → execute →
397
- * afterStep failure-handling. Returns a typed outcome the loop uses to
398
- * accumulate results, decide whether to break, and remember the failed step.
399
- *
400
- * Wraps the per-step lifecycle in a `try / finally` that calls
401
- * `opts.disposeStepResources()` so per-step state (the
402
- * `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
403
- * removed even when the step throws, times out, or rule-skips.
572
+ * Restore every spec, surfacing each as a `cache:restore` pseudo-step. Returns
573
+ * a map keyed by spec key recording whether the EXACT key hit (so the save
574
+ * phase can skip a redundant save of an entry that already exists).
404
575
  */
405
- async function runStepIteration(step, stepIndex, opts) {
406
- const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
407
- if (skippedResult) {
408
- await opts.disposeStepResources?.();
409
- return {
410
- result: skippedResult,
411
- shouldBreak: false
412
- };
413
- }
414
- if (opts.jobHooks?.beforeStep) await runObserverHook({
415
- hook: opts.jobHooks.beforeStep,
416
- hookType: "beforeStep",
417
- step,
418
- stepIndex,
419
- hookStepIndex: opts.steps.length + stepIndex * 2,
420
- opts
421
- });
422
- try {
423
- const result = await executeStepInLoop(step, stepIndex, opts.createStepContext(stepIndex, step.name), step.timeout ?? opts.defaultTimeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords);
424
- if (opts.jobHooks?.afterStep) await runObserverHook({
425
- hook: opts.jobHooks.afterStep,
426
- hookType: "afterStep",
427
- step,
576
+ async function restoreCacheSpecs(specs, deps) {
577
+ const results = /* @__PURE__ */ new Map();
578
+ for (const spec of specs) {
579
+ const stepIndex = deps.nextStepIndex();
580
+ deps.sendIpc({
581
+ type: "step.start",
428
582
  stepIndex,
429
- hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
430
- failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
431
- opts
583
+ stepName: `cache restore: ${spec.key}`,
584
+ step_type: CacheStepType.enum["cache:restore"]
432
585
  });
433
- if (result.status === ExecutionStepStatus.enum.failed) return {
434
- result,
435
- shouldBreak: !step.continueOnError,
436
- failedStepName: step.name
437
- };
438
- return {
439
- result,
440
- shouldBreak: false
441
- };
442
- } finally {
443
- await opts.disposeStepResources?.();
586
+ const start = Date.now();
587
+ try {
588
+ const r = await deps.cache.restore(spec);
589
+ results.set(spec.key, {
590
+ hit: r.hit,
591
+ matchedKey: r.matchedKey
592
+ });
593
+ deps.sendIpc({
594
+ type: "step.complete",
595
+ stepIndex,
596
+ status: "success",
597
+ durationMs: Date.now() - start,
598
+ step_type: CacheStepType.enum["cache:restore"],
599
+ data: {
600
+ cacheOutcome: r.hit ? CacheOutcome.enum.hit : CacheOutcome.enum.miss,
601
+ key: spec.key,
602
+ ...r.matchedKey !== void 0 && { matchedKey: r.matchedKey }
603
+ }
604
+ });
605
+ } catch (e) {
606
+ results.set(spec.key, { hit: false });
607
+ deps.sendIpc({
608
+ type: "step.complete",
609
+ stepIndex,
610
+ status: "failed",
611
+ durationMs: Date.now() - start,
612
+ error: { message: toErrorMessage(e) },
613
+ step_type: CacheStepType.enum["cache:restore"],
614
+ data: {
615
+ cacheOutcome: CacheOutcome.enum.error,
616
+ key: spec.key
617
+ }
618
+ });
619
+ }
444
620
  }
621
+ return results;
445
622
  }
446
623
  /**
447
- * Execute one named completion hook (onSuccess / onFailure / cleanup) and
448
- * return the updated `CompletionState`. Treated as the single source of truth
449
- * for the "promote to failed + concat reason" pattern that the three completion
450
- * hooks share.
624
+ * Save every spec whose EXACT key did not already hit on restore (immutable +
625
+ * no redundant save), surfacing each as a `cache:save` pseudo-step. A spec
626
+ * whose restore matched a different key via a `restoreKeys` prefix is still
627
+ * saved under its exact key.
451
628
  */
452
- async function runCompletionHook(args) {
453
- const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
454
- opts.sendIpc({
455
- type: "log.line",
456
- stepIndex: -1,
457
- line: `[kici] Running ${hookType} hook...`
458
- });
459
- const hookResult = await executeHook({
460
- hook,
461
- stepContext: opts.createStepContext(hookStepIndex, hookType),
462
- outcome,
463
- hookType,
464
- stepIndex: hookStepIndex,
465
- sendIpc: opts.sendIpc
466
- });
467
- if (hookResult.success) {
468
- opts.sendIpc({
469
- type: "log.line",
470
- stepIndex: -1,
471
- line: `[kici] ${hookType} hook completed`
629
+ async function saveCacheSpecs(specs, restoreResults, deps) {
630
+ for (const spec of specs) {
631
+ if (restoreResults.get(spec.key)?.matchedKey === spec.key) continue;
632
+ const stepIndex = deps.nextStepIndex();
633
+ deps.sendIpc({
634
+ type: "step.start",
635
+ stepIndex,
636
+ stepName: `cache save: ${spec.key}`,
637
+ step_type: CacheStepType.enum["cache:save"]
472
638
  });
473
- return state;
639
+ const start = Date.now();
640
+ try {
641
+ await deps.cache.save(spec);
642
+ deps.sendIpc({
643
+ type: "step.complete",
644
+ stepIndex,
645
+ status: "success",
646
+ durationMs: Date.now() - start,
647
+ step_type: CacheStepType.enum["cache:save"],
648
+ data: {
649
+ cacheOutcome: CacheOutcome.enum.saved,
650
+ key: spec.key
651
+ }
652
+ });
653
+ } catch (e) {
654
+ deps.sendIpc({
655
+ type: "step.complete",
656
+ stepIndex,
657
+ status: "failed",
658
+ durationMs: Date.now() - start,
659
+ error: { message: toErrorMessage(e) },
660
+ step_type: CacheStepType.enum["cache:save"],
661
+ data: {
662
+ cacheOutcome: CacheOutcome.enum.error,
663
+ key: spec.key
664
+ }
665
+ });
666
+ }
474
667
  }
475
- opts.sendIpc({
476
- type: "log.line",
477
- stepIndex: -1,
478
- line: `[kici] ${hookType} hook failed: ${hookResult.error}`
479
- });
480
- const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
481
- return {
482
- failed: state.failed || promoteToFailed,
483
- failedStepName: state.failedStepName,
484
- failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
485
- };
486
668
  }
669
+ //#endregion
670
+ //#region src/execution/sandbox/log-masker.ts
487
671
  /**
488
- * Run the job-completion hook sequence after the per-step loop ends:
489
- * onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
490
- * recomputed so it reflects any failures introduced by onSuccess/onFailure.
672
+ * Secret value masking for log lines.
673
+ *
674
+ * Replaces all occurrences of registered secret values with '***' in log output.
675
+ * Used by the workflow runner to prevent secret leaks in IPC log messages.
676
+ *
677
+ * Performance: Builds a single combined regex from all secret values, so each
678
+ * log line is scanned in a single pass (not O(secrets * lines)).
491
679
  */
492
- async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
493
- const { jobHooks } = opts;
494
- const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
495
- const jobOutcome = buildOutcomeMetadata({
496
- status: initialFinalStatus,
497
- stepOutputs: Object.fromEntries(outputsMap),
498
- startTime,
499
- ...initial.failedStepName && {
500
- failedStep: initial.failedStepName,
501
- reason: `Step '${initial.failedStepName}' failed`
502
- }
503
- });
504
- let state = initial;
505
- let hookStepIndex = opts.steps.length;
506
- if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
507
- state = await runCompletionHook({
508
- hook: jobHooks.onSuccess,
509
- hookType: "onSuccess",
510
- hookStepIndex,
511
- outcome: jobOutcome,
512
- state,
513
- promoteToFailed: true,
514
- opts
515
- });
516
- hookStepIndex++;
517
- } else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
518
- state = await runCompletionHook({
519
- hook: jobHooks.onFailure,
520
- hookType: "onFailure",
521
- hookStepIndex,
522
- outcome: jobOutcome,
523
- state,
524
- promoteToFailed: false,
525
- opts
526
- });
527
- hookStepIndex++;
528
- }
529
- if (jobHooks?.cleanup) {
530
- const cleanupOutcome = buildOutcomeMetadata({
531
- status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
532
- stepOutputs: Object.fromEntries(outputsMap),
533
- startTime,
534
- ...state.failedStepName && { failedStep: state.failedStepName },
535
- ...state.failureReason && { reason: state.failureReason }
536
- });
537
- state = await runCompletionHook({
538
- hook: jobHooks.cleanup,
539
- hookType: "cleanup",
540
- hookStepIndex,
541
- outcome: cleanupOutcome,
542
- state,
543
- promoteToFailed: true,
544
- opts
545
- });
546
- }
547
- return state;
680
+ /** Minimum length for a secret value to be maskable (avoids false positives). */
681
+ const MIN_MASK_LENGTH = 3;
682
+ /**
683
+ * Escape regex special characters in a string.
684
+ */
685
+ function escapeRegExp(s) {
686
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
548
687
  }
549
688
  /**
550
- * Execute the step loop with hook integration and step-level rule evaluation.
689
+ * Masks secret values in log lines.
551
690
  *
552
- * Hook execution order:
553
- * - beforeStep -> step -> afterStep (per step)
554
- * - onSuccess or onFailure (after all steps)
555
- * - cleanup (always, after onSuccess/onFailure)
691
+ * Usage:
692
+ * ```ts
693
+ * const masker = new LogMasker();
694
+ * masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
695
+ * masker.mask('Token is abc123'); // 'Token is ***'
696
+ * // 'ab' is NOT masked (< 3 chars)
697
+ * ```
698
+ */
699
+ var LogMasker = class {
700
+ pattern = null;
701
+ /**
702
+ * Register secret values to be masked in log output.
703
+ *
704
+ * Values shorter than 3 characters are skipped to avoid false positives.
705
+ * Base64-encoded variants of each qualifying secret are also registered,
706
+ * preventing leaks when secrets appear base64-encoded in logs (e.g.,
707
+ * Authorization: Basic headers, base64-encoded config values).
708
+ * Values are sorted by length descending so longer values are matched first
709
+ * (prevents partial masking when one secret is a substring of another).
710
+ */
711
+ registerSecrets(secrets) {
712
+ const seen = /* @__PURE__ */ new Set();
713
+ const values = [];
714
+ for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
715
+ seen.add(value);
716
+ values.push(value);
717
+ const b64 = Buffer.from(value).toString("base64");
718
+ if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
719
+ seen.add(b64);
720
+ values.push(b64);
721
+ }
722
+ }
723
+ if (values.length === 0) {
724
+ this.pattern = null;
725
+ return;
726
+ }
727
+ values.sort((a, b) => b.length - a.length);
728
+ this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
729
+ }
730
+ /**
731
+ * Mask all registered secret values in a log line.
732
+ *
733
+ * Returns the line unchanged if no secrets are registered.
734
+ */
735
+ mask(line) {
736
+ if (!this.pattern) return line;
737
+ this.pattern.lastIndex = 0;
738
+ return line.replace(this.pattern, "***");
739
+ }
740
+ /**
741
+ * Returns true if any maskable secrets are registered.
742
+ */
743
+ hasSecrets() {
744
+ return this.pattern !== null;
745
+ }
746
+ };
747
+ //#endregion
748
+ //#region src/execution/sandbox/env-delta.ts
749
+ /**
750
+ * Apply an environment delta to `target` (defaults to process.env), honoring the
751
+ * operator-secret guard.
556
752
  *
557
- * Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
558
- * Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
753
+ * - env keys present in `operatorSecretKeys` are rejected (never override an
754
+ * operator secret); `onReject` fires once per rejected key.
755
+ * - pathPrepends are applied so the FIRST array entry ends up FIRST on PATH.
559
756
  */
560
- async function executeStepLoop(opts) {
561
- const startTime = opts.startTime ?? Date.now();
562
- const stepResults = [];
563
- const state = { failed: false };
564
- for (const [i, step] of opts.steps.entries()) {
565
- if (opts.isAborted?.()) break;
566
- const outcome = await runStepIteration(step, i, opts);
567
- stepResults.push(outcome.result);
568
- if (outcome.failedStepName) {
569
- state.failed = true;
570
- state.failedStepName = outcome.failedStepName;
757
+ function applyEnvDelta(delta, options) {
758
+ const target = options.target ?? process.env;
759
+ const appliedKeys = [];
760
+ const rejectedKeys = [];
761
+ for (const [key, value] of Object.entries(delta.env)) {
762
+ if (options.operatorSecretKeys.has(key)) {
763
+ rejectedKeys.push(key);
764
+ options.onReject?.(key);
765
+ continue;
571
766
  }
572
- if (outcome.shouldBreak) break;
767
+ target[key] = value;
768
+ appliedKeys.push(key);
769
+ }
770
+ const appliedPaths = [];
771
+ if (delta.pathPrepends.length > 0) {
772
+ for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}:${target.PATH}` : dir;
773
+ appliedPaths.push(...delta.pathPrepends);
573
774
  }
574
- if (opts.isAborted?.()) return {
575
- status: "aborted",
576
- stepResults,
577
- failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
578
- };
579
- const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
580
775
  return {
581
- status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
582
- stepResults,
583
- failureReason: finalState.failureReason
776
+ appliedKeys,
777
+ rejectedKeys,
778
+ appliedPaths
584
779
  };
585
780
  }
586
781
  //#endregion
587
- //#region src/checkout/ssh-auth.ts
782
+ //#region src/execution/sandbox/env-file.ts
588
783
  /**
589
- * Materialize an SSH private key (and optional pinned known_hosts) into a
590
- * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
784
+ * KICI_ENV / KICI_PATH temp-file contract.
591
785
  *
592
- * Permissions:
593
- * - private key mode 0o600 (required by OpenSSH refuses to use world-
594
- * readable keys).
595
- * - known_hosts mode 0o600.
596
- * - tempdir mode 0o700.
786
+ * Before each step the agent points the KICI_ENV and KICI_PATH env vars at fresh
787
+ * temp files. A step's shell commands append `KEY=value` lines to $KICI_ENV and
788
+ * one directory per line to $KICI_PATH. After the step the agent parses both
789
+ * files into an EnvDelta and feeds it through applyEnvDelta() -- the same path
790
+ * the JS API (ctx.setEnv / ctx.addPath) uses -- then truncates the files for the
791
+ * next step.
597
792
  *
598
- * SSH flags composed:
599
- * - `-i <keyfile>` identity file.
600
- * - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
601
- * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
602
- * - host-key checking flags based on `hostKeyPolicy`.
793
+ * Format v1: single-line `KEY=value` for env (no embedded newlines); one
794
+ * directory per line for path. Blank lines and lines without `=` are ignored.
603
795
  */
604
- async function setupSshAuth(opts) {
605
- if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
606
- const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
607
- const keyPath = join(tempDir, "id");
608
- await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
609
- const knownHostsPath = join(tempDir, "known_hosts");
610
- await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
611
- const parts = [
612
- "ssh",
613
- "-i",
614
- escapeShellArg(keyPath),
615
- "-o",
616
- "IdentitiesOnly=yes",
617
- "-o",
618
- "BatchMode=yes",
619
- "-o",
620
- `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
621
- ];
622
- if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
623
- else parts.push("-o", "StrictHostKeyChecking=accept-new");
796
+ /**
797
+ * Parse `KEY=value` lines into a record. Blank lines, lines without `=`, and
798
+ * lines with an empty key are ignored. The split is on the first `=` only, so a
799
+ * value may contain `=`. The key is trimmed; the value is taken verbatim after
800
+ * the first `=`. Last assignment to a key wins.
801
+ */
802
+ function parseEnvFileContent(content) {
803
+ const out = {};
804
+ for (const rawLine of content.split("\n")) {
805
+ const line = rawLine.trim();
806
+ if (line.length === 0) continue;
807
+ const eq = line.indexOf("=");
808
+ if (eq <= 0) continue;
809
+ const key = line.slice(0, eq).trim();
810
+ if (key.length === 0) continue;
811
+ out[key] = line.slice(eq + 1);
812
+ }
813
+ return out;
814
+ }
815
+ /** Parse one trimmed directory per non-blank line, preserving order. */
816
+ function parsePathFileContent(content) {
817
+ const out = [];
818
+ for (const rawLine of content.split("\n")) {
819
+ const line = rawLine.trim();
820
+ if (line.length === 0) continue;
821
+ out.push(line);
822
+ }
823
+ return out;
824
+ }
825
+ /** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
826
+ async function createEnvFiles(baseDir) {
827
+ const dir = await mkdtemp(join(baseDir, "kici-env-"));
828
+ const envFile = join(dir, "env");
829
+ const pathFile = join(dir, "path");
830
+ await writeFile(envFile, "");
831
+ await writeFile(pathFile, "");
624
832
  return {
625
- gitSshCommand: parts.join(" "),
626
- tempDir,
627
- async cleanup() {
628
- await rm(tempDir, {
629
- recursive: true,
630
- force: true
631
- });
632
- }
833
+ envFile,
834
+ pathFile
835
+ };
836
+ }
837
+ /** Read + parse both files into an EnvDelta. Missing/empty files yield an empty delta. */
838
+ async function readEnvDelta(files) {
839
+ const [envContent, pathContent] = await Promise.all([readFile(files.envFile, "utf8").catch(() => ""), readFile(files.pathFile, "utf8").catch(() => "")]);
840
+ return {
841
+ env: parseEnvFileContent(envContent),
842
+ pathPrepends: parsePathFileContent(pathContent)
633
843
  };
634
844
  }
845
+ /** Truncate both files to empty so the next step starts clean. */
846
+ async function truncateEnvFiles(files) {
847
+ await Promise.all([writeFile(files.envFile, ""), writeFile(files.pathFile, "")]);
848
+ }
849
+ //#endregion
850
+ //#region src/execution/sandbox/secret-merge.ts
635
851
  /**
636
- * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
637
- * wrapping so backslashes and spaces survive git's shell-parse of the
638
- * command value.
852
+ * Secret merging utilities for the workflow runner.
853
+ *
854
+ * Separated from workflow-runner.ts to allow unit testing without
855
+ * triggering the runner's top-level side effects (process handlers, main()).
639
856
  */
640
- function escapeShellArg(value) {
641
- return `'${value.replace(/'/g, "'\\''")}'`;
857
+ /**
858
+ * Merge orchestrator-level secrets with auto-flattened context keys.
859
+ *
860
+ * Precedence (last wins):
861
+ * 1. Orchestrator-level secrets (lowest)
862
+ * 2. Context-flattened keys in declaration order (each context's keys overlay previous)
863
+ *
864
+ * This means: context-flattened keys override orchestrator-level secrets,
865
+ * and for collisions between contexts, last declared context wins.
866
+ */
867
+ function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
868
+ const merged = { ...orchestratorSecrets };
869
+ for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
870
+ return merged;
642
871
  }
643
872
  //#endregion
644
- //#region src/checkout/git-clone.ts
873
+ //#region src/execution/hook-executor.ts
874
+ /** Default hook timeout: 5 minutes */
875
+ const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
645
876
  /**
646
- * Strip auth credentials from git error messages to prevent token leakage.
647
- * Node's execFileSync includes the full command line (including -c http.extraHeader
648
- * and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
649
- * tokens or the absolute path of a temporary SSH key file in logs.
877
+ * Build outcome metadata from execution state.
878
+ *
879
+ * Duration is calculated as elapsed time since startTime.
650
880
  */
651
- function sanitizeGitError(error) {
652
- if (!(error instanceof Error)) return new Error(String(error));
653
- const sanitized = new Error(redactSensitive(error.message));
654
- sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
655
- return sanitized;
881
+ function buildOutcomeMetadata(opts) {
882
+ return {
883
+ status: opts.status,
884
+ reason: opts.reason,
885
+ failedStep: opts.failedStep,
886
+ stepOutputs: opts.stepOutputs,
887
+ duration: Date.now() - opts.startTime
888
+ };
656
889
  }
657
- function redactSensitive(input) {
658
- return input.replace(/http\.extraHeader=Authorization: Basic \S+/g, "http.extraHeader=Authorization: Basic [REDACTED]").replace(/'[^']*kici-ssh-[^']*'/g, "'[REDACTED_SSH_PATH]'").replace(/\S*kici-ssh-\S+/g, "[REDACTED_SSH_PATH]");
890
+ /**
891
+ * Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
892
+ */
893
+ function normalizeHook(hook, hookType) {
894
+ if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
895
+ if (typeof hook === "function") return {
896
+ name: hookType,
897
+ type: hookType,
898
+ run: hook
899
+ };
900
+ if (typeof hook === "object" && "run" in hook) return {
901
+ name: hookType,
902
+ type: hookType,
903
+ run: hook.run,
904
+ timeout: hook.timeout
905
+ };
906
+ throw new Error(`Invalid hook input for ${hookType}`);
659
907
  }
660
908
  /**
661
- * Shallow-clone a git repository at a specific ref with optional token auth.
662
- *
663
- * Token authentication uses git's `-c http.extraHeader` mechanism which keeps
664
- * the token out of the clone URL (not visible in `git remote -v` or logs).
665
- *
666
- * After clone, verifies that HEAD matches the expected SHA to prevent
667
- * wrong-ref execution.
909
+ * Execute a single hook with timeout enforcement and IPC reporting.
668
910
  *
669
- * @throws Error if clone fails or SHA does not match
911
+ * Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
912
+ * The hook runs in the same sandbox context as regular steps.
670
913
  */
671
- async function gitClone(options) {
672
- const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
673
- const auth = gitAuth ? gitAuth : token ? {
674
- kind: "basic",
675
- user: "x-access-token",
676
- secret: token
677
- } : void 0;
678
- const args = [];
679
- const envEntries = {};
680
- let needsCustomEnv = false;
681
- let safeDirCleanup;
682
- if (repoUrl.startsWith("file://")) {
683
- const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
684
- const { tmpdir } = await import("node:os");
685
- const path = await import("node:path");
686
- const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
687
- const cfgPath = path.join(dir, "config");
688
- await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
689
- envEntries.GIT_CONFIG_GLOBAL = cfgPath;
690
- needsCustomEnv = true;
691
- safeDirCleanup = async () => {
692
- await rm(dir, {
693
- recursive: true,
694
- force: true
695
- }).catch(() => {});
696
- };
697
- }
698
- let sshSetup;
914
+ async function executeHook(opts) {
915
+ const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
916
+ const normalized = normalizeHook(opts.hook, hookType);
917
+ const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
918
+ sendIpc({
919
+ type: "step.start",
920
+ stepIndex,
921
+ stepName: normalized.name,
922
+ step_type: `hook:${hookType}`
923
+ });
924
+ const startTime = Date.now();
925
+ const mergedCtx = {
926
+ ...stepContext,
927
+ outcome
928
+ };
929
+ const abortController = new AbortController();
930
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
699
931
  try {
700
- if (auth?.kind === "basic") {
701
- const user = auth.user ?? "x-access-token";
702
- const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
703
- args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
704
- } else if (auth?.kind === "ssh") {
705
- sshSetup = await setupSshAuth({
706
- privateKey: auth.secret,
707
- hostKeyPolicy: auth.sshHostKeyPolicy,
708
- knownHosts: auth.sshKnownHostsPem
709
- });
710
- envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
711
- needsCustomEnv = true;
712
- }
713
- const env = needsCustomEnv ? {
714
- ...process.env,
715
- ...envEntries
716
- } : void 0;
717
- if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
718
- else args.push("clone", "--depth", String(depth), repoUrl, workDir);
719
- const { execFileSync } = await import("node:child_process");
720
- try {
721
- execFileSync("git", args, {
722
- stdio: "pipe",
723
- timeout: 12e4,
724
- ...env && { env }
725
- });
726
- } catch (err) {
727
- throw sanitizeGitError(err);
728
- }
729
- if (!sha || sha === "HEAD") return;
730
- const envOpts = env ? { env } : {};
731
- if (!execFileSync("git", [
732
- "-C",
733
- workDir,
734
- "rev-parse",
735
- "HEAD"
736
- ], {
737
- encoding: "utf-8",
738
- timeout: 1e4,
739
- ...envOpts
740
- }).trim().startsWith(sha)) {
741
- const fetchArgs = [];
742
- if (auth?.kind === "basic") {
743
- const user = auth.user ?? "x-access-token";
744
- const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
745
- fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
746
- }
747
- fetchArgs.push("fetch", "--depth", "50", "origin", sha);
748
- try {
749
- execFileSync("git", [
750
- "-C",
751
- workDir,
752
- ...fetchArgs
753
- ], {
754
- stdio: "pipe",
755
- timeout: 12e4,
756
- ...envOpts
757
- });
758
- } catch (err) {
759
- throw sanitizeGitError(err);
760
- }
761
- execFileSync("git", [
762
- "-C",
763
- workDir,
764
- "checkout",
765
- sha
766
- ], {
767
- stdio: "pipe",
768
- timeout: 3e4,
769
- ...envOpts
932
+ await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
933
+ abortController.signal.addEventListener("abort", () => {
934
+ reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
770
935
  });
771
- const recheckedSha = execFileSync("git", [
772
- "-C",
773
- workDir,
774
- "rev-parse",
775
- "HEAD"
776
- ], {
777
- encoding: "utf-8",
778
- timeout: 1e4,
779
- ...envOpts
780
- }).trim();
781
- if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
782
- }
783
- } finally {
784
- if (sshSetup) await sshSetup.cleanup().catch(() => {});
785
- if (safeDirCleanup) await safeDirCleanup();
936
+ })]);
937
+ clearTimeout(timeoutId);
938
+ sendIpc({
939
+ type: "step.complete",
940
+ stepIndex,
941
+ status: "success",
942
+ durationMs: Date.now() - startTime,
943
+ step_type: `hook:${hookType}`
944
+ });
945
+ return { success: true };
946
+ } catch (e) {
947
+ clearTimeout(timeoutId);
948
+ const durationMs = Date.now() - startTime;
949
+ const error = toErrorMessage(e);
950
+ sendIpc({
951
+ type: "step.complete",
952
+ stepIndex,
953
+ status: "failed",
954
+ durationMs,
955
+ error: { message: error },
956
+ step_type: `hook:${hookType}`
957
+ });
958
+ return {
959
+ success: false,
960
+ error
961
+ };
786
962
  }
787
963
  }
788
964
  //#endregion
789
- //#region src/execution/dep-restore.ts
965
+ //#region src/execution/rule-evaluator.ts
966
+ initZx();
790
967
  /**
791
- * Dependency restoration from cached tarballs.
792
- *
793
- * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
794
- * and extracts to .kici/node_modules/ in the work directory.
968
+ * Create RuleContext for agent-side rule evaluation.
795
969
  *
796
- * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
797
- * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
798
- * file:// URLs use a buffer-based approach (local, no streaming benefit).
970
+ * @param event - Event payload from the dispatch message
971
+ * @param changedFiles - List of files changed in this event
972
+ * @param env - Merged environment variables
973
+ */
974
+ function createRuleContext(event, changedFiles = [], env = {}) {
975
+ return {
976
+ event,
977
+ changedFiles,
978
+ env,
979
+ $
980
+ };
981
+ }
982
+ //#endregion
983
+ //#region src/execution/sandbox/step-loop.ts
984
+ /**
985
+ * Execute a single step with timeout enforcement.
799
986
  *
800
- * Streaming downloads have a 5-minute timeout and up to 2 retries.
987
+ * Timeout pattern using Promise.race + AbortController, with IPC status reporting.
988
+ */
989
+ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords, jobDeadlineSignal) {
990
+ sendFn({
991
+ type: "step.start",
992
+ stepIndex,
993
+ stepName: step.name
994
+ });
995
+ const startTime = Date.now();
996
+ const abortController = new AbortController();
997
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
998
+ try {
999
+ const result = await Promise.race([
1000
+ step.run(ctx),
1001
+ new Promise((_, reject) => {
1002
+ abortController.signal.addEventListener("abort", () => {
1003
+ reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
1004
+ });
1005
+ }),
1006
+ new Promise((_, reject) => {
1007
+ if (!jobDeadlineSignal) return;
1008
+ if (jobDeadlineSignal.aborted) {
1009
+ reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
1010
+ return;
1011
+ }
1012
+ jobDeadlineSignal.addEventListener("abort", () => {
1013
+ reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
1014
+ });
1015
+ })
1016
+ ]);
1017
+ clearTimeout(timeoutId);
1018
+ const durationMs = Date.now() - startTime;
1019
+ const outputsPayload = result != null ? result : void 0;
1020
+ if (outputsPayload) outputsMap.set(step.name, outputsPayload);
1021
+ const secretsAccessed = getSecretsAccessLog?.();
1022
+ emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1023
+ sendFn({
1024
+ type: "step.complete",
1025
+ stepIndex,
1026
+ status: ExecutionStepStatus.enum.success,
1027
+ durationMs,
1028
+ ...outputsPayload && { outputs: outputsPayload },
1029
+ ...secretsAccessed !== void 0 && { secretsAccessed }
1030
+ });
1031
+ return {
1032
+ name: step.name,
1033
+ stepIndex,
1034
+ status: ExecutionStepStatus.enum.success,
1035
+ durationMs,
1036
+ ...outputsPayload && { outputs: outputsPayload }
1037
+ };
1038
+ } catch (e) {
1039
+ clearTimeout(timeoutId);
1040
+ const durationMs = Date.now() - startTime;
1041
+ const error = e instanceof Error ? e : new Error(String(e));
1042
+ const exitCode = extractExitCode(e);
1043
+ const signal = extractSignal(e);
1044
+ const secretsAccessed = getSecretsAccessLog?.();
1045
+ emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1046
+ sendFn({
1047
+ type: "step.complete",
1048
+ stepIndex,
1049
+ status: ExecutionStepStatus.enum.failed,
1050
+ durationMs,
1051
+ error: {
1052
+ message: error.message,
1053
+ ...exitCode !== void 0 && { exitCode },
1054
+ ...signal !== void 0 && { signal }
1055
+ },
1056
+ ...secretsAccessed !== void 0 && { secretsAccessed }
1057
+ });
1058
+ return {
1059
+ name: step.name,
1060
+ stepIndex,
1061
+ status: ExecutionStepStatus.enum.failed,
1062
+ durationMs,
1063
+ error: {
1064
+ message: error.message,
1065
+ ...exitCode !== void 0 && { exitCode },
1066
+ ...signal !== void 0 && { signal }
1067
+ }
1068
+ };
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
1073
+ * the step performed. Called from both the success and failure paths so the
1074
+ * orchestrator's audit trail records every mount regardless of step outcome.
801
1075
  */
802
- const logger$3 = createLogger({ prefix: "dep-restore" });
803
- /** Download timeout: 5 minutes. */
804
- const DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
1076
+ function emitSecretMountEvents(records, stepIndex, sendFn) {
1077
+ if (!records || records.length === 0) return;
1078
+ for (const record of records) sendFn({
1079
+ type: "step.secret_mount",
1080
+ stepIndex,
1081
+ sources: record.sources,
1082
+ target: record.target,
1083
+ kind: record.kind,
1084
+ ...record.envVar !== void 0 && { envVar: record.envVar }
1085
+ });
1086
+ }
1087
+ function extractExitCode(error) {
1088
+ if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
1089
+ }
1090
+ function extractSignal(error) {
1091
+ if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
1092
+ }
805
1093
  /**
806
- * Compute SHA-256 hash of a buffer.
1094
+ * Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
1095
+ * fails; returns null when the step should run normally.
807
1096
  */
808
- function computeHash(data) {
809
- return sha256(data);
1097
+ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1098
+ if (!step.rules || step.rules.length === 0) return null;
1099
+ const ruleCtx = createRuleContext(opts.event, [], opts.env);
1100
+ const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
1101
+ if (ruleResult.allPassed) return null;
1102
+ opts.sendIpc({
1103
+ type: "step.start",
1104
+ stepIndex,
1105
+ stepName: step.name
1106
+ });
1107
+ opts.sendIpc({
1108
+ type: "step.complete",
1109
+ stepIndex,
1110
+ status: ExecutionStepStatus.enum.failed,
1111
+ durationMs: 0
1112
+ });
1113
+ opts.sendIpc({
1114
+ type: "log.line",
1115
+ stepIndex,
1116
+ line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
1117
+ });
1118
+ return {
1119
+ name: step.name,
1120
+ stepIndex,
1121
+ status: ExecutionStepStatus.enum.skipped,
1122
+ durationMs: 0
1123
+ };
810
1124
  }
811
1125
  /**
812
- * Extract a gzip tarball from a buffer into the target directory.
813
- * Used for file:// URLs where streaming provides no benefit.
1126
+ * Manual approval gate for a step. When the step declares `requireApproval`
1127
+ * and the harness wired `awaitStepApproval`, block until the orchestrator
1128
+ * resolves the hold. Returns a failed `StepIterationOutcome` (breaking the
1129
+ * loop) on reject/expired; returns null when approved or when no gate applies.
814
1130
  */
815
- async function extractTarball(data, targetDir) {
816
- await mkdir(targetDir, { recursive: true });
817
- const readable = Readable.from(data);
818
- await new Promise((resolve, reject) => {
819
- readable.pipe(x({
820
- cwd: targetDir,
821
- gzip: true
822
- })).on("finish", resolve).on("error", reject);
1131
+ async function maybeGateStepApproval(step, stepIndex, opts) {
1132
+ if (step.requireApproval === void 0 || !opts.awaitStepApproval) return null;
1133
+ const normalized = normalizeRequireApproval(step.requireApproval);
1134
+ opts.sendIpc({
1135
+ type: "log.line",
1136
+ stepIndex,
1137
+ line: `[kici] Step '${step.name}' awaiting approval...`
1138
+ });
1139
+ const resolution = await opts.awaitStepApproval({
1140
+ stepIndex,
1141
+ stepName: step.name,
1142
+ clauses: normalized.clauses,
1143
+ reason: normalized.reason ?? `Approval required for step '${step.name}'`,
1144
+ timeoutSeconds: normalized.timeoutSeconds
1145
+ });
1146
+ if (resolution.outcome === "approved") {
1147
+ opts.sendIpc({
1148
+ type: "log.line",
1149
+ stepIndex,
1150
+ line: `[kici] Step '${step.name}' approved.`
1151
+ });
1152
+ return null;
1153
+ }
1154
+ const why = resolution.outcome === "expired" ? "approval expired" : `approval rejected${resolution.reason ? `: ${resolution.reason}` : ""}`;
1155
+ opts.sendIpc({
1156
+ type: "step.start",
1157
+ stepIndex,
1158
+ stepName: step.name
1159
+ });
1160
+ opts.sendIpc({
1161
+ type: "step.complete",
1162
+ stepIndex,
1163
+ status: ExecutionStepStatus.enum.failed,
1164
+ durationMs: 0
1165
+ });
1166
+ opts.sendIpc({
1167
+ type: "log.line",
1168
+ stepIndex,
1169
+ line: `[kici] Step '${step.name}' ${why}.`
1170
+ });
1171
+ await opts.disposeStepResources?.();
1172
+ return {
1173
+ result: {
1174
+ name: step.name,
1175
+ stepIndex,
1176
+ status: ExecutionStepStatus.enum.failed,
1177
+ durationMs: 0,
1178
+ error: { message: `Step '${step.name}' ${why}` }
1179
+ },
1180
+ shouldBreak: true,
1181
+ failedStepName: step.name
1182
+ };
1183
+ }
1184
+ /**
1185
+ * Run a single observer hook (beforeStep / afterStep). Failures only emit a
1186
+ * log line — they never change job status. Centralises the per-call boilerplate
1187
+ * so the per-step body can stay flat.
1188
+ */
1189
+ async function runObserverHook(args) {
1190
+ const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
1191
+ const outcome = buildOutcomeMetadata({
1192
+ status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
1193
+ stepOutputs: Object.fromEntries(opts.outputsMap),
1194
+ startTime: opts.startTime ?? Date.now(),
1195
+ ...failedStep !== void 0 && { failedStep }
1196
+ });
1197
+ const hookResult = await executeHook({
1198
+ hook,
1199
+ stepContext: opts.createStepContext(stepIndex, step.name),
1200
+ outcome,
1201
+ hookType,
1202
+ stepIndex: hookStepIndex,
1203
+ sendIpc: opts.sendIpc,
1204
+ timeout: 3e5
1205
+ });
1206
+ if (!hookResult.success) opts.sendIpc({
1207
+ type: "log.line",
1208
+ stepIndex,
1209
+ line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
823
1210
  });
824
1211
  }
825
1212
  /**
826
- * Stream download and extract an HTTP/HTTPS tarball.
1213
+ * Execute one iteration of the step loop: step rules → beforeStep → execute →
1214
+ * afterStep → failure-handling. Returns a typed outcome the loop uses to
1215
+ * accumulate results, decide whether to break, and remember the failed step.
827
1216
  *
828
- * Computes SHA-256 hash on the fly via a Transform stream.
829
- * Returns the computed hash of the compressed tarball data.
1217
+ * Wraps the per-step lifecycle in a `try / finally` that calls
1218
+ * `opts.disposeStepResources()` so per-step state (the
1219
+ * `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
1220
+ * removed even when the step throws, times out, or rule-skips.
830
1221
  */
831
- async function streamFetchAndExtract(url, targetDir) {
832
- const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
833
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
834
- if (!response.body) throw new Error("No response body");
835
- const nodeStream = Readable.fromWeb(response.body);
836
- const hash = createHash("sha256");
837
- const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
838
- hash.update(chunk);
839
- callback(null, chunk);
840
- } });
841
- await mkdir(targetDir, { recursive: true });
842
- await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
843
- return hash.digest("hex");
1222
+ async function runStepIteration(step, stepIndex, opts) {
1223
+ const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1224
+ if (skippedResult) {
1225
+ await opts.disposeStepResources?.();
1226
+ return {
1227
+ result: skippedResult,
1228
+ shouldBreak: false
1229
+ };
1230
+ }
1231
+ const gate = await maybeGateStepApproval(step, stepIndex, opts);
1232
+ if (gate) return gate;
1233
+ if (opts.jobHooks?.beforeStep) await runObserverHook({
1234
+ hook: opts.jobHooks.beforeStep,
1235
+ hookType: "beforeStep",
1236
+ step,
1237
+ stepIndex,
1238
+ hookStepIndex: opts.steps.length + stepIndex * 2,
1239
+ opts
1240
+ });
1241
+ const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
1242
+ const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps) : /* @__PURE__ */ new Map();
1243
+ try {
1244
+ await opts.beforeStepEnvFiles?.();
1245
+ const ctx = opts.createStepContext(stepIndex, step.name);
1246
+ const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
1247
+ let result;
1248
+ try {
1249
+ result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal);
1250
+ } finally {
1251
+ await opts.afterStepApplyEnvFiles?.();
1252
+ }
1253
+ if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps);
1254
+ if (opts.jobHooks?.afterStep) await runObserverHook({
1255
+ hook: opts.jobHooks.afterStep,
1256
+ hookType: "afterStep",
1257
+ step,
1258
+ stepIndex,
1259
+ hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
1260
+ failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
1261
+ opts
1262
+ });
1263
+ if (result.status === ExecutionStepStatus.enum.failed) return {
1264
+ result,
1265
+ shouldBreak: !step.continueOnError,
1266
+ failedStepName: step.name
1267
+ };
1268
+ return {
1269
+ result,
1270
+ shouldBreak: false
1271
+ };
1272
+ } finally {
1273
+ await opts.disposeStepResources?.();
1274
+ }
844
1275
  }
845
1276
  /**
846
- * Path-relative-to-`.kici/` glob that matches every scratch dir
847
- * `extractIntoScratch` may create. Surfaced so the clone phase can register
848
- * it in `.git/info/exclude` (see `excludeScratchFromGit`) keeping the glob
849
- * and the exclude rule in the same file means future renames of the scratch
850
- * dir prefix can't fall out of sync with the git-ignore wiring.
851
- */
852
- const SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
853
- /**
854
- * Glob suitable for `.gitignore` / `.git/info/exclude` that matches every
855
- * scratch dir created by `extractIntoScratch`, anchored to the workflow
856
- * working tree's `.kici/` subdir.
857
- */
858
- const SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
859
- /**
860
- * Extract the dep tarball into a per-attempt scratch dir so retries never race
861
- * with still-draining I/O from a previous failed attempt.
862
- *
863
- * When `pipeline()` rejects on a network error or AbortSignal timeout, the
864
- * underlying `tar.x` continues flushing pending file writes for an unbounded
865
- * window after the promise settles — `pipeline` does not block on async
866
- * filesystem side effects. If the next retry then runs `rm -rf` on the same
867
- * `node_modules/`, the walk races with those writes and `rmdir` fails with
868
- * ENOTEMPTY (new files keep appearing under a directory we just emptied).
869
- *
870
- * We sidestep the race entirely by extracting each attempt into a unique
871
- * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
872
- * draining writes are harmless — the next attempt does not touch them. On
873
- * success `moveScratchIntoRepo` renames the extracted entries into place
874
- * (atomic on the same filesystem), then best-effort cleans the scratch dir.
875
- *
876
- * Scratch dirs land inside the customer's cloned working tree, so the clone
877
- * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
878
- * keep them out of `git status` for any workflow step that shells out to git.
879
- * See `excludeScratchFromGit`.
1277
+ * Execute one named completion hook (onSuccess / onFailure / cleanup) and
1278
+ * return the updated `CompletionState`. Treated as the single source of truth
1279
+ * for the "promote to failed + concat reason" pattern that the three completion
1280
+ * hooks share.
880
1281
  */
881
- async function extractIntoScratch(url, kiciDir, attempt) {
882
- const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
883
- await mkdir(scratchDir, { recursive: true });
1282
+ async function runCompletionHook(args) {
1283
+ const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
1284
+ opts.sendIpc({
1285
+ type: "log.line",
1286
+ stepIndex: -1,
1287
+ line: `[kici] Running ${hookType} hook...`
1288
+ });
1289
+ const hookResult = await executeHook({
1290
+ hook,
1291
+ stepContext: opts.createStepContext(hookStepIndex, hookType),
1292
+ outcome,
1293
+ hookType,
1294
+ stepIndex: hookStepIndex,
1295
+ sendIpc: opts.sendIpc
1296
+ });
1297
+ if (hookResult.success) {
1298
+ opts.sendIpc({
1299
+ type: "log.line",
1300
+ stepIndex: -1,
1301
+ line: `[kici] ${hookType} hook completed`
1302
+ });
1303
+ return state;
1304
+ }
1305
+ opts.sendIpc({
1306
+ type: "log.line",
1307
+ stepIndex: -1,
1308
+ line: `[kici] ${hookType} hook failed: ${hookResult.error}`
1309
+ });
1310
+ const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
884
1311
  return {
885
- scratchDir,
886
- hash: await streamFetchAndExtract(url, scratchDir)
1312
+ failed: state.failed || promoteToFailed,
1313
+ failedStepName: state.failedStepName,
1314
+ failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
887
1315
  };
888
1316
  }
889
1317
  /**
890
- * Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
891
- * so any in-flight or orphaned dep-restore scratch dirs are invisible to
892
- * `git status` / `git add` inside the customer's cloned working tree.
893
- *
894
- * Why `.git/info/exclude` and not `.gitignore`:
895
- * - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
896
- * modify it. Doing so would surface the rule in their PRs and create a
897
- * diff customers never asked for.
898
- * - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
899
- * mechanism for "ignore these patterns in THIS working tree". Git creates
900
- * an empty (template-commented) file on `git init` / `git clone`, so it
901
- * already exists by the time we're called.
902
- *
903
- * Why this lives next to `extractIntoScratch`:
904
- * - The exclude glob is tied 1:1 to the scratch dir naming convention. If
905
- * the prefix ever changes, the rule must change too. Defining both in the
906
- * same file means a rename touches one place, not two.
907
- *
908
- * Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
909
- * `git clone` and the dir layout differs) we log and continue — failing the
910
- * job over a missing git ignore wiring would be worse than the cosmetic
911
- * issue we're solving.
1318
+ * Run the job-completion hook sequence after the per-step loop ends:
1319
+ * onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
1320
+ * recomputed so it reflects any failures introduced by onSuccess/onFailure.
1321
+ */
1322
+ async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
1323
+ const { jobHooks } = opts;
1324
+ const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
1325
+ const jobOutcome = buildOutcomeMetadata({
1326
+ status: initialFinalStatus,
1327
+ stepOutputs: Object.fromEntries(outputsMap),
1328
+ startTime,
1329
+ ...initial.failedStepName && {
1330
+ failedStep: initial.failedStepName,
1331
+ reason: `Step '${initial.failedStepName}' failed`
1332
+ }
1333
+ });
1334
+ let state = initial;
1335
+ let hookStepIndex = opts.steps.length;
1336
+ if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
1337
+ state = await runCompletionHook({
1338
+ hook: jobHooks.onSuccess,
1339
+ hookType: "onSuccess",
1340
+ hookStepIndex,
1341
+ outcome: jobOutcome,
1342
+ state,
1343
+ promoteToFailed: true,
1344
+ opts
1345
+ });
1346
+ hookStepIndex++;
1347
+ } else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
1348
+ state = await runCompletionHook({
1349
+ hook: jobHooks.onFailure,
1350
+ hookType: "onFailure",
1351
+ hookStepIndex,
1352
+ outcome: jobOutcome,
1353
+ state,
1354
+ promoteToFailed: false,
1355
+ opts
1356
+ });
1357
+ hookStepIndex++;
1358
+ }
1359
+ if (jobHooks?.cleanup) {
1360
+ const cleanupOutcome = buildOutcomeMetadata({
1361
+ status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
1362
+ stepOutputs: Object.fromEntries(outputsMap),
1363
+ startTime,
1364
+ ...state.failedStepName && { failedStep: state.failedStepName },
1365
+ ...state.failureReason && { reason: state.failureReason }
1366
+ });
1367
+ state = await runCompletionHook({
1368
+ hook: jobHooks.cleanup,
1369
+ hookType: "cleanup",
1370
+ hookStepIndex,
1371
+ outcome: cleanupOutcome,
1372
+ state,
1373
+ promoteToFailed: true,
1374
+ opts
1375
+ });
1376
+ }
1377
+ return state;
1378
+ }
1379
+ /**
1380
+ * Execute the step loop with hook integration and step-level rule evaluation.
912
1381
  *
913
- * Idempotent: callers may invoke this multiple times (dual-clone path, retry
914
- * after partial setup). We skip the append if the glob is already present.
1382
+ * Hook execution order:
1383
+ * - beforeStep -> step -> afterStep (per step)
1384
+ * - onSuccess or onFailure (after all steps)
1385
+ * - cleanup (always, after onSuccess/onFailure)
915
1386
  *
916
- * @param repoWorkDir - The git working tree root (the dir that contains
917
- * `.git/`). For normal workflows this is the agent's job workDir; for
918
- * global workflows it is the workflow repo dir (whose `.kici/` carries
919
- * the scratch dirs).
1387
+ * Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
1388
+ * Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
920
1389
  */
921
- async function excludeScratchFromGit(repoWorkDir) {
922
- const excludePath = join(repoWorkDir, ".git", "info", "exclude");
923
- try {
924
- const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
925
- if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
926
- const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
927
- await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
928
- } catch (err) {
929
- logger$3.warn("Failed to register scratch dir glob in .git/info/exclude", {
930
- excludePath,
931
- error: err instanceof Error ? err.message : String(err)
932
- });
1390
+ async function executeStepLoop(opts) {
1391
+ const startTime = opts.startTime ?? Date.now();
1392
+ const stepResults = [];
1393
+ const state = { failed: false };
1394
+ for (const [i, step] of opts.steps.entries()) {
1395
+ if (opts.isAborted?.()) break;
1396
+ const outcome = await runStepIteration(step, i, opts);
1397
+ stepResults.push(outcome.result);
1398
+ if (outcome.failedStepName) {
1399
+ state.failed = true;
1400
+ state.failedStepName = outcome.failedStepName;
1401
+ }
1402
+ if (outcome.shouldBreak) break;
933
1403
  }
1404
+ if (opts.isAborted?.()) return {
1405
+ status: "aborted",
1406
+ stepResults,
1407
+ failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
1408
+ };
1409
+ const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
1410
+ return {
1411
+ status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
1412
+ stepResults,
1413
+ failureReason: finalState.failureReason
1414
+ };
934
1415
  }
1416
+ //#endregion
1417
+ //#region src/execution/env-init/init-phase.ts
1418
+ /** Default init timeout when a spec sets none: 10 minutes. */
1419
+ const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
935
1420
  /**
936
- * Rewrite localhost URLs to use the orchestrator host.
937
- *
938
- * The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
939
- * but agent containers can't reach localhost. This utility replaces the
940
- * host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
1421
+ * Marker thrown when an init command exceeds its wall-clock budget. Carries the
1422
+ * distinct P3 timeout reason so the phase result + job.complete report a timeout
1423
+ * rather than a generic failure.
941
1424
  */
942
- function resolveOrchestratorUrl(url) {
943
- if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
944
- const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
945
- if (!orchestratorUrl) return url;
946
- try {
947
- const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
948
- const parsed = new URL(url);
949
- parsed.hostname = orchestratorParsed.hostname;
950
- return parsed.toString();
951
- } catch {
952
- return url;
1425
+ var InitTimeoutError = class extends Error {
1426
+ reason = TimeoutReason.enum.job_timeout;
1427
+ constructor(timeoutMs) {
1428
+ super(`init command exceeded its timeout of ${timeoutMs}ms`);
1429
+ this.name = "InitTimeoutError";
1430
+ }
1431
+ };
1432
+ /** Run all init specs in order; stop + fail at the first non-zero / timeout. */
1433
+ async function runInitPhase(opts) {
1434
+ if (!opts.specs || opts.specs.length === 0) return { ok: true };
1435
+ for (let i = 0; i < opts.specs.length; i++) {
1436
+ const spec = opts.specs[i];
1437
+ const stepIndex = opts.stepIndexBase + i;
1438
+ const stepType = `init:${i}`;
1439
+ const outcome = await runOneInit(spec, i, stepIndex, stepType, opts);
1440
+ if (!outcome.ok) return {
1441
+ ok: false,
1442
+ failedInitIndex: i,
1443
+ error: outcome.error,
1444
+ ...outcome.timedOut && {
1445
+ timedOut: true,
1446
+ reason: outcome.reason
1447
+ }
1448
+ };
953
1449
  }
1450
+ return { ok: true };
954
1451
  }
955
1452
  /**
956
- * Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
957
- * packed repo-root-relative, so the scratch holds repo-root entries:
958
- * `.kici/node_modules` for every manager, plus (for pnpm) the root
959
- * `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
960
- * already exists in the work tree (cloned or source-restored), so its children
961
- * are moved individually; every other top-level entry is moved wholesale.
962
- *
963
- * On a cache-hit execution agent the destinations do not pre-exist (source
964
- * restore excludes node_modules and never carries sibling dirs), so the renames
965
- * have nothing to race; the defensive `rm` covers re-runs.
1453
+ * Run a promise against a wall-clock budget. Aborts via an AbortController on
1454
+ * breach (mirroring the step loop) and rejects with an {@link InitTimeoutError}
1455
+ * carrying the distinct P3 timeout reason. Resolves with the command's value
1456
+ * when it finishes first.
966
1457
  */
967
- async function moveScratchIntoRepo(scratchDir, workDir) {
968
- for (const child of await fsPromises.readdir(scratchDir)) if (child === ".kici") {
969
- const kiciScratch = join(scratchDir, ".kici");
970
- for (const sub of await fsPromises.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
971
- } else await moveInto(join(scratchDir, child), join(workDir, child));
1458
+ function withInitTimeout(run, timeoutMs) {
1459
+ const ac = new AbortController();
1460
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
1461
+ return Promise.race([run, new Promise((_, reject) => {
1462
+ ac.signal.addEventListener("abort", () => reject(new InitTimeoutError(timeoutMs)));
1463
+ })]).finally(() => clearTimeout(timer));
972
1464
  }
973
- /** Move `src` to `dest`, creating the parent and clearing any stale dest. */
974
- async function moveInto(src, dest) {
975
- await mkdir(dirname(dest), { recursive: true });
976
- await fsPromises.rm(dest, {
977
- recursive: true,
978
- force: true
1465
+ async function runOneInit(spec, index, stepIndex, stepType, opts) {
1466
+ opts.sendIpc({
1467
+ type: "step.start",
1468
+ stepIndex,
1469
+ stepName: stepType,
1470
+ step_type: stepType
979
1471
  });
980
- await fsPromises.rename(src, dest);
981
- }
982
- /** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
983
- async function cleanupScratch(scratchDir) {
1472
+ const start = Date.now();
1473
+ const shell = spec.shell ?? "bash";
984
1474
  try {
985
- await fsPromises.rm(scratchDir, {
986
- recursive: true,
987
- force: true
1475
+ let cacheHit = false;
1476
+ if (spec.cache && opts.cache) cacheHit = (await opts.cache.restore(spec.cache)).hit;
1477
+ await opts.env?.beginCapture();
1478
+ const $ = opts.shellFor(spec, index);
1479
+ const timeoutMs = spec.timeout ?? DEFAULT_INIT_TIMEOUT_MS;
1480
+ await withInitTimeout($`${shell} -c ${spec.run}`, timeoutMs);
1481
+ if (spec.cache && opts.cache && !cacheHit) await opts.cache.save(spec.cache);
1482
+ await opts.env?.applyDelta();
1483
+ opts.sendIpc({
1484
+ type: "step.complete",
1485
+ stepIndex,
1486
+ status: ExecutionStepStatus.enum.success,
1487
+ durationMs: Date.now() - start,
1488
+ step_type: stepType
988
1489
  });
989
- } catch (cleanupErr) {
990
- logger$3.warn("Scratch dir cleanup failed (orphan left behind)", {
991
- scratchDir,
992
- error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
1490
+ return { ok: true };
1491
+ } catch (e) {
1492
+ const error = toErrorMessage(e);
1493
+ const timedOut = e instanceof InitTimeoutError;
1494
+ const exitCode = e && typeof e === "object" && "exitCode" in e && typeof e.exitCode === "number" ? e.exitCode : void 0;
1495
+ opts.sendIpc({
1496
+ type: "step.complete",
1497
+ stepIndex,
1498
+ status: ExecutionStepStatus.enum.failed,
1499
+ durationMs: Date.now() - start,
1500
+ error: {
1501
+ message: error,
1502
+ ...exitCode !== void 0 && { exitCode }
1503
+ },
1504
+ step_type: stepType
993
1505
  });
1506
+ return {
1507
+ ok: false,
1508
+ error,
1509
+ ...timedOut && {
1510
+ timedOut: true,
1511
+ reason: e.reason
1512
+ }
1513
+ };
994
1514
  }
995
1515
  }
1516
+ //#endregion
1517
+ //#region src/execution/sandbox/job-deadline.ts
996
1518
  /**
997
- * Restore dependencies from a cached tarball into the cloned repo.
998
- *
999
- * The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
1000
- * carries `.kici/node_modules`; pnpm additionally carries the root
1001
- * `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
1002
- * Restore extracts into a scratch dir, then moves each entry into place — one
1003
- * code path for all managers.
1519
+ * Arm a job-level wall-clock deadline. When `timeoutMs` is set and elapses
1520
+ * before clear() is called, invokes `onTimeout` with the distinct
1521
+ * `job_timeout` reason and the configured budget. A no-op when `timeoutMs`
1522
+ * is undefined (no job-level cap configured).
1523
+ */
1524
+ function armJobDeadline(timeoutMs, onTimeout) {
1525
+ if (timeoutMs === void 0 || timeoutMs <= 0) return { clear: () => {} };
1526
+ const timer = setTimeout(() => {
1527
+ onTimeout(TimeoutReason.enum.job_timeout, timeoutMs);
1528
+ }, timeoutMs);
1529
+ timer.unref?.();
1530
+ return { clear: () => clearTimeout(timer) };
1531
+ }
1532
+ //#endregion
1533
+ //#region src/checkout/ssh-auth.ts
1534
+ /**
1535
+ * Materialize an SSH private key (and optional pinned known_hosts) into a
1536
+ * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
1004
1537
  *
1005
- * For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
1006
- * with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
1007
- * For file:// URLs: a buffer-based approach (local, no streaming benefit).
1538
+ * Permissions:
1539
+ * - private key mode 0o600 (required by OpenSSH refuses to use world-
1540
+ * readable keys).
1541
+ * - known_hosts mode 0o600.
1542
+ * - tempdir mode 0o700.
1008
1543
  *
1009
- * @param workDir - Root directory of the cloned repository
1010
- * @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
1011
- * @param depsHash - Optional expected SHA-256 hash of the tarball
1544
+ * SSH flags composed:
1545
+ * - `-i <keyfile>` identity file.
1546
+ * - `-o IdentitiesOnly=yes` don't try other keys from ssh-agent / ~/.ssh.
1547
+ * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
1548
+ * - host-key checking flags based on `hostKeyPolicy`.
1012
1549
  */
1013
- async function restoreDeps(workDir, depsUrl, depsHash) {
1014
- depsUrl = resolveOrchestratorUrl(depsUrl);
1015
- logger$3.info("Downloading dependency tarball", { url: depsUrl });
1016
- const kiciDir = join(workDir, ".kici");
1017
- if (depsUrl.startsWith("file://")) {
1018
- const localPath = fileURLToPath(depsUrl);
1019
- const data = await fsPromises.readFile(localPath);
1020
- if (depsHash) {
1021
- const actualHash = computeHash(data);
1022
- if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
1550
+ async function setupSshAuth(opts) {
1551
+ if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
1552
+ const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
1553
+ const keyPath = join(tempDir, "id");
1554
+ await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
1555
+ const knownHostsPath = join(tempDir, "known_hosts");
1556
+ await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
1557
+ const parts = [
1558
+ "ssh",
1559
+ "-i",
1560
+ escapeShellArg(keyPath),
1561
+ "-o",
1562
+ "IdentitiesOnly=yes",
1563
+ "-o",
1564
+ "BatchMode=yes",
1565
+ "-o",
1566
+ `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
1567
+ ];
1568
+ if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
1569
+ else parts.push("-o", "StrictHostKeyChecking=accept-new");
1570
+ return {
1571
+ gitSshCommand: parts.join(" "),
1572
+ tempDir,
1573
+ async cleanup() {
1574
+ await rm(tempDir, {
1575
+ recursive: true,
1576
+ force: true
1577
+ });
1023
1578
  }
1024
- const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
1025
- await extractTarball(data, scratchDir);
1026
- await moveScratchIntoRepo(scratchDir, workDir);
1027
- await cleanupScratch(scratchDir);
1028
- const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
1029
- logger$3.info("Dependencies restored from cache (file)", {
1030
- sizeMB,
1031
- targetDir: workDir
1032
- });
1033
- return;
1579
+ };
1580
+ }
1581
+ /**
1582
+ * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
1583
+ * wrapping so backslashes and spaces survive git's shell-parse of the
1584
+ * command value.
1585
+ */
1586
+ function escapeShellArg(value) {
1587
+ return `'${value.replace(/'/g, "'\\''")}'`;
1588
+ }
1589
+ //#endregion
1590
+ //#region src/checkout/git-clone.ts
1591
+ /**
1592
+ * Strip auth credentials from git error messages to prevent token leakage.
1593
+ * Node's execFileSync includes the full command line (including -c http.extraHeader
1594
+ * and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
1595
+ * tokens or the absolute path of a temporary SSH key file in logs.
1596
+ */
1597
+ function sanitizeGitError(error) {
1598
+ if (!(error instanceof Error)) return new Error(String(error));
1599
+ const sanitized = new Error(redactSensitive(error.message));
1600
+ sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
1601
+ return sanitized;
1602
+ }
1603
+ function redactSensitive(input) {
1604
+ return input.replace(/http\.extraHeader=Authorization: Basic \S+/g, "http.extraHeader=Authorization: Basic [REDACTED]").replace(/'[^']*kici-ssh-[^']*'/g, "'[REDACTED_SSH_PATH]'").replace(/\S*kici-ssh-\S+/g, "[REDACTED_SSH_PATH]");
1605
+ }
1606
+ /**
1607
+ * Shallow-clone a git repository at a specific ref with optional token auth.
1608
+ *
1609
+ * Token authentication uses git's `-c http.extraHeader` mechanism which keeps
1610
+ * the token out of the clone URL (not visible in `git remote -v` or logs).
1611
+ *
1612
+ * After clone, verifies that HEAD matches the expected SHA to prevent
1613
+ * wrong-ref execution.
1614
+ *
1615
+ * @throws Error if clone fails or SHA does not match
1616
+ */
1617
+ async function gitClone(options) {
1618
+ const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
1619
+ const auth = gitAuth ? gitAuth : token ? {
1620
+ kind: "basic",
1621
+ user: "x-access-token",
1622
+ secret: token
1623
+ } : void 0;
1624
+ const args = [];
1625
+ const envEntries = {};
1626
+ let needsCustomEnv = false;
1627
+ let safeDirCleanup;
1628
+ if (repoUrl.startsWith("file://")) {
1629
+ const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
1630
+ const { tmpdir } = await import("node:os");
1631
+ const path = await import("node:path");
1632
+ const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
1633
+ const cfgPath = path.join(dir, "config");
1634
+ await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
1635
+ envEntries.GIT_CONFIG_GLOBAL = cfgPath;
1636
+ needsCustomEnv = true;
1637
+ safeDirCleanup = async () => {
1638
+ await rm(dir, {
1639
+ recursive: true,
1640
+ force: true
1641
+ }).catch(() => {});
1642
+ };
1034
1643
  }
1035
- if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
1036
- let lastError;
1037
- for (let attempt = 0; attempt <= 2; attempt++) {
1038
- if (attempt > 0) logger$3.warn("Retrying dep tarball download", {
1039
- attempt,
1040
- url: depsUrl
1041
- });
1644
+ let sshSetup;
1645
+ try {
1646
+ if (auth?.kind === "basic") {
1647
+ const user = auth.user ?? "x-access-token";
1648
+ const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
1649
+ args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
1650
+ } else if (auth?.kind === "ssh") {
1651
+ sshSetup = await setupSshAuth({
1652
+ privateKey: auth.secret,
1653
+ hostKeyPolicy: auth.sshHostKeyPolicy,
1654
+ knownHosts: auth.sshKnownHostsPem
1655
+ });
1656
+ envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
1657
+ needsCustomEnv = true;
1658
+ }
1659
+ const env = needsCustomEnv ? {
1660
+ ...process.env,
1661
+ ...envEntries
1662
+ } : void 0;
1663
+ if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
1664
+ else args.push("clone", "--depth", String(depth), repoUrl, workDir);
1665
+ const { execFileSync } = await import("node:child_process");
1042
1666
  try {
1043
- const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
1044
- if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
1045
- await moveScratchIntoRepo(scratchDir, workDir);
1046
- await cleanupScratch(scratchDir);
1047
- logger$3.info("Dependencies restored from cache (stream)", { targetDir: workDir });
1048
- return;
1667
+ execFileSync("git", args, {
1668
+ stdio: "pipe",
1669
+ timeout: 12e4,
1670
+ ...env && { env }
1671
+ });
1049
1672
  } catch (err) {
1050
- lastError = err instanceof Error ? err : new Error(String(err));
1051
- logger$3.warn("Dep tarball download failed", {
1052
- attempt,
1053
- error: lastError.message
1673
+ throw sanitizeGitError(err);
1674
+ }
1675
+ if (!sha || sha === "HEAD") return;
1676
+ const envOpts = env ? { env } : {};
1677
+ if (!execFileSync("git", [
1678
+ "-C",
1679
+ workDir,
1680
+ "rev-parse",
1681
+ "HEAD"
1682
+ ], {
1683
+ encoding: "utf-8",
1684
+ timeout: 1e4,
1685
+ ...envOpts
1686
+ }).trim().startsWith(sha)) {
1687
+ const fetchArgs = [];
1688
+ if (auth?.kind === "basic") {
1689
+ const user = auth.user ?? "x-access-token";
1690
+ const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
1691
+ fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
1692
+ }
1693
+ fetchArgs.push("fetch", "--depth", "50", "origin", sha);
1694
+ try {
1695
+ execFileSync("git", [
1696
+ "-C",
1697
+ workDir,
1698
+ ...fetchArgs
1699
+ ], {
1700
+ stdio: "pipe",
1701
+ timeout: 12e4,
1702
+ ...envOpts
1703
+ });
1704
+ } catch (err) {
1705
+ throw sanitizeGitError(err);
1706
+ }
1707
+ execFileSync("git", [
1708
+ "-C",
1709
+ workDir,
1710
+ "checkout",
1711
+ sha
1712
+ ], {
1713
+ stdio: "pipe",
1714
+ timeout: 3e4,
1715
+ ...envOpts
1054
1716
  });
1717
+ const recheckedSha = execFileSync("git", [
1718
+ "-C",
1719
+ workDir,
1720
+ "rev-parse",
1721
+ "HEAD"
1722
+ ], {
1723
+ encoding: "utf-8",
1724
+ timeout: 1e4,
1725
+ ...envOpts
1726
+ }).trim();
1727
+ if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
1055
1728
  }
1729
+ } finally {
1730
+ if (sshSetup) await sshSetup.cleanup().catch(() => {});
1731
+ if (safeDirCleanup) await safeDirCleanup();
1056
1732
  }
1057
- throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
1058
1733
  }
1059
1734
  //#endregion
1060
1735
  //#region src/execution/npm-resolver.ts
@@ -1565,8 +2240,8 @@ function logSubprocessStreams(e, tokens) {
1565
2240
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
1566
2241
  * Node's normal ESM lookup against `.kici/node_modules/`.
1567
2242
  */
1568
- const AGENT_SDK_VERSION = "0.1.14";
1569
- const AGENT_SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
2243
+ const AGENT_SDK_VERSION = "0.1.16";
2244
+ const AGENT_SDK_BUNDLE_HASH = "cd4b8e0d91efe578c96e7c746f89b44a3df8fcaebaec2185399168ef8eb4ebca";
1570
2245
  /**
1571
2246
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
1572
2247
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -1727,40 +2402,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
1727
2402
  throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
1728
2403
  }
1729
2404
  //#endregion
1730
- //#region src/execution/download.ts
1731
- /**
1732
- * Shared HTTP/HTTPS download utility.
1733
- *
1734
- * Extracted from workflow-loader.ts to avoid duplication across
1735
- * dep-restore.ts and workflow-loader.ts.
1736
- */
1737
- /** Download timeout: 5 minutes. */
1738
- const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
1739
- /**
1740
- * Download content from an HTTP/HTTPS URL.
1741
- *
1742
- * Includes a 5-minute timeout to prevent the agent from hanging indefinitely
1743
- * on slow or unresponsive endpoints.
1744
- *
1745
- * @param url - The URL to download from
1746
- * @returns The response body as a Buffer
1747
- */
1748
- function downloadUrl(url) {
1749
- return new Promise((resolve, reject) => {
1750
- (url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
1751
- if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
1752
- reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
1753
- res.resume();
1754
- return;
1755
- }
1756
- const chunks = [];
1757
- res.on("data", (chunk) => chunks.push(chunk));
1758
- res.on("end", () => resolve(Buffer.concat(chunks)));
1759
- res.on("error", reject);
1760
- }).on("error", reject);
1761
- });
1762
- }
1763
- //#endregion
1764
2405
  //#region src/execution/source-restore.ts
1765
2406
  /**
1766
2407
  * `.kici/` source tarball restoration for execution agents.
@@ -1783,6 +2424,8 @@ function downloadUrl(url) {
1783
2424
  * runs without a preceding build (cache infrastructure unavailable, or a
1784
2425
  * build job that failed but left dynamic dispatch in flight).
1785
2426
  */
2427
+ init_download();
2428
+ init_dep_restore();
1786
2429
  const logger$1 = createLogger({ prefix: "source-restore" });
1787
2430
  async function extractSourceTarball(data, targetDir) {
1788
2431
  await mkdir(targetDir, { recursive: true });
@@ -1824,6 +2467,7 @@ async function restoreSource(workDir, sourceTarUrl) {
1824
2467
  * Wire format: [12-byte IV][16-byte auth tag][ciphertext]
1825
2468
  * Same encryption scheme as packages/compiler/src/remote/encryption.ts.
1826
2469
  */
2470
+ init_download();
1827
2471
  const logger = createLogger({ prefix: "overlay-applier" });
1828
2472
  const IV_LENGTH = 12;
1829
2473
  const AUTH_TAG_LENGTH = 16;
@@ -1957,6 +2601,7 @@ async function applyOverlay(config) {
1957
2601
  * This file is compiled alongside the agent by rolldown (existing build), but
1958
2602
  * runs as a SEPARATE process spawned by the sandbox backend.
1959
2603
  */
2604
+ init_dep_restore();
1960
2605
  process.on("uncaughtException", (err) => {
1961
2606
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
1962
2607
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -2139,6 +2784,21 @@ let aborted = false;
2139
2784
  */
2140
2785
  let forceAborted = false;
2141
2786
  /**
2787
+ * Set when the job-level wall-clock deadline (the lock job's `timeout`) is
2788
+ * breached. Trips the same abort path cancellation uses, but tags the
2789
+ * job.complete with the distinct TimeoutReason.job_timeout reason.
2790
+ */
2791
+ let jobTimedOut = false;
2792
+ /** The configured job timeout budget in ms, captured when the deadline fires. */
2793
+ let jobTimedOutMs;
2794
+ /**
2795
+ * Aborted when the job-level deadline fires. Threaded into the step loop so the
2796
+ * IN-FLIGHT step (e.g. a long-running `sleep`) is interrupted immediately on
2797
+ * breach — the between-steps `isAborted()` check alone cannot unwind a single
2798
+ * long step that has no per-step `timeout`.
2799
+ */
2800
+ const jobDeadlineAbort = new AbortController();
2801
+ /**
2142
2802
  * Pending promises for event.emit requests awaiting responses from the agent.
2143
2803
  *
2144
2804
  * Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
@@ -2245,6 +2905,190 @@ function waitForApiResponse(requestId) {
2245
2905
  });
2246
2906
  }
2247
2907
  /**
2908
+ * Pending promises for cache.response messages from the agent.
2909
+ * Key: requestId (correlates cache.request -> cache.response).
2910
+ */
2911
+ const pendingCacheResponses = /* @__PURE__ */ new Map();
2912
+ /** Default timeout for a cache request relay (matches the upload-URL request budget). */
2913
+ const CACHE_RESPONSE_TIMEOUT_MS = 3e4;
2914
+ /** Wait for a cache.response from the agent with the given requestId. */
2915
+ function waitForCacheResponse(requestId) {
2916
+ return new Promise((resolve, reject) => {
2917
+ const timer = setTimeout(() => {
2918
+ pendingCacheResponses.delete(requestId);
2919
+ reject(/* @__PURE__ */ new Error(`Cache request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
2920
+ }, CACHE_RESPONSE_TIMEOUT_MS);
2921
+ pendingCacheResponses.set(requestId, {
2922
+ resolve: (response) => {
2923
+ clearTimeout(timer);
2924
+ pendingCacheResponses.delete(requestId);
2925
+ resolve(response);
2926
+ },
2927
+ reject: (err) => {
2928
+ clearTimeout(timer);
2929
+ pendingCacheResponses.delete(requestId);
2930
+ reject(err);
2931
+ },
2932
+ timer
2933
+ });
2934
+ });
2935
+ }
2936
+ /**
2937
+ * Pending promises for approval.resolved messages from the agent.
2938
+ * Key: requestId (correlates approval.request -> approval.resolved).
2939
+ */
2940
+ const pendingApprovalResolutions = /* @__PURE__ */ new Map();
2941
+ /**
2942
+ * Hard ceiling on how long the runner blocks a step waiting for an approval
2943
+ * resolution. The orchestrator enforces the real (org-/SDK-configured) expiry
2944
+ * and sends `expired` when it lapses; this is a safety net well above any sane
2945
+ * approval window so the runner cannot hang forever if the resolution is lost.
2946
+ */
2947
+ const APPROVAL_RESOLUTION_TIMEOUT_MS = 10080 * 60 * 1e3;
2948
+ /** Wait for an approval.resolved from the agent with the given requestId. */
2949
+ function waitForApprovalResolution(requestId) {
2950
+ return new Promise((resolve) => {
2951
+ const timer = setTimeout(() => {
2952
+ pendingApprovalResolutions.delete(requestId);
2953
+ resolve({
2954
+ type: "approval.resolved",
2955
+ requestId,
2956
+ outcome: "expired"
2957
+ });
2958
+ }, APPROVAL_RESOLUTION_TIMEOUT_MS);
2959
+ pendingApprovalResolutions.set(requestId, {
2960
+ resolve: (response) => {
2961
+ clearTimeout(timer);
2962
+ pendingApprovalResolutions.delete(requestId);
2963
+ resolve(response);
2964
+ },
2965
+ timer
2966
+ });
2967
+ });
2968
+ }
2969
+ /**
2970
+ * Build the `awaitStepApproval` callback the step loop uses to block on a
2971
+ * `requireApproval` step. Sends an `approval.request` IPC (relayed by the agent
2972
+ * over the WS as a `step.approval-request`) and awaits the matching
2973
+ * `approval.resolved`. A relay error is treated as a fail-closed reject.
2974
+ */
2975
+ function buildAwaitStepApproval() {
2976
+ return async (req) => {
2977
+ const requestId = randomUUID();
2978
+ sendMessage({
2979
+ type: "approval.request",
2980
+ requestId,
2981
+ stepIndex: req.stepIndex,
2982
+ stepName: req.stepName,
2983
+ clauses: req.clauses,
2984
+ reason: req.reason,
2985
+ ...req.timeoutSeconds !== void 0 && { timeoutSeconds: req.timeoutSeconds }
2986
+ });
2987
+ const resolution = await waitForApprovalResolution(requestId);
2988
+ if (resolution.error) return {
2989
+ outcome: "rejected",
2990
+ reason: resolution.error
2991
+ };
2992
+ return {
2993
+ outcome: resolution.outcome ?? "rejected",
2994
+ ...resolution.reason !== void 0 && { reason: resolution.reason }
2995
+ };
2996
+ };
2997
+ }
2998
+ /**
2999
+ * Build the {@link CacheTransport} the sandbox-side cache engine uses to reach
3000
+ * the orchestrator. Each method sends a `cache.request` IPC (relayed by the
3001
+ * agent over the WS as a `cache.user.*` message) and awaits the matching
3002
+ * `cache.response`. Mirrors the `event.emit` / `agent.api.request` relays.
3003
+ */
3004
+ function buildCacheTransport() {
3005
+ return {
3006
+ async restore(key, restoreKeys) {
3007
+ const requestId = randomUUID();
3008
+ sendMessage({
3009
+ type: "cache.request",
3010
+ requestId,
3011
+ op: "restore",
3012
+ key,
3013
+ ...restoreKeys && { restoreKeys }
3014
+ });
3015
+ const response = await waitForCacheResponse(requestId);
3016
+ if (response.error) throw new Error(`Cache restore failed: ${response.error}`);
3017
+ return {
3018
+ hit: response.hit ?? false,
3019
+ ...response.matchedKey && { matchedKey: response.matchedKey },
3020
+ ...response.downloadUrl && { downloadUrl: response.downloadUrl },
3021
+ ...response.tarHash && { tarHash: response.tarHash }
3022
+ };
3023
+ },
3024
+ async beginSave(key) {
3025
+ const requestId = randomUUID();
3026
+ sendMessage({
3027
+ type: "cache.request",
3028
+ requestId,
3029
+ op: "beginSave",
3030
+ key
3031
+ });
3032
+ const response = await waitForCacheResponse(requestId);
3033
+ if (response.error) throw new Error(`Cache save failed: ${response.error}`);
3034
+ return {
3035
+ skip: response.skip ?? true,
3036
+ ...response.uploadUrl && { uploadUrl: response.uploadUrl }
3037
+ };
3038
+ },
3039
+ async completeSave(key, tarHash, sizeBytes) {
3040
+ const requestId = randomUUID();
3041
+ sendMessage({
3042
+ type: "cache.request",
3043
+ requestId,
3044
+ op: "completeSave",
3045
+ key,
3046
+ tarHash,
3047
+ sizeBytes
3048
+ });
3049
+ const response = await waitForCacheResponse(requestId);
3050
+ if (response.error) throw new Error(`Cache save-complete failed: ${response.error}`);
3051
+ }
3052
+ };
3053
+ }
3054
+ /**
3055
+ * Build the declarative-cache phase dependencies and run the job-level cache
3056
+ * restore (Phase 9b).
3057
+ *
3058
+ * The deps carry one job-scoped cache API (over the same IPC→WS transport
3059
+ * `ctx.cache` uses) plus a monotonic pseudo-step index allocator that starts
3060
+ * well above every real-step and hook index (hooks use up to `stepCount * 3`).
3061
+ * Cache restores/saves surface as `cache:restore` / `cache:save` pseudo-steps
3062
+ * allocated from this cursor. The returned `jobCacheRestore` map lets the
3063
+ * post-loop save phase skip exact-key hits.
3064
+ */
3065
+ async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
3066
+ let cacheStepCursor = stepCount * 3 + 100;
3067
+ const cachePhaseDeps = {
3068
+ cache: createCacheApi(stepCwd, buildCacheTransport()),
3069
+ sendIpc,
3070
+ nextStepIndex: () => cacheStepCursor++
3071
+ };
3072
+ const jobCacheSpecs = normalizeCacheSpecs(jobCache);
3073
+ return {
3074
+ cachePhaseDeps,
3075
+ jobCacheSpecs,
3076
+ jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
3077
+ };
3078
+ }
3079
+ /**
3080
+ * Job-level cache save (Phase 9c). Runs after the step loop, only when every
3081
+ * step succeeded and the job was not aborted. Each spec whose exact key did NOT
3082
+ * already hit on restore is saved (immutable: a re-save of an existing exact
3083
+ * key is skipped by the save phase itself). A no-op when the job declared no
3084
+ * cache or did not fully succeed (a failed/aborted job's artifacts may be
3085
+ * partial).
3086
+ */
3087
+ async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
3088
+ if (specs.length === 0 || !succeeded) return;
3089
+ await saveCacheSpecs(specs, restoreResults, deps);
3090
+ }
3091
+ /**
2248
3092
  * Dispatch an agent-to-runner message to the appropriate pending handler.
2249
3093
  * Shared between fork mode (IPC channel) and stdio mode (stdin readline).
2250
3094
  */
@@ -2261,6 +3105,12 @@ function dispatchAgentMessage(msg) {
2261
3105
  const pending = pendingApiResponses.get(msg.requestId);
2262
3106
  if (pending) if (msg.error) pending.reject(new Error(msg.error));
2263
3107
  else pending.resolve(msg.result);
3108
+ } else if (msg.type === "cache.response") {
3109
+ const pending = pendingCacheResponses.get(msg.requestId);
3110
+ if (pending) pending.resolve(msg);
3111
+ } else if (msg.type === "approval.resolved") {
3112
+ const pending = pendingApprovalResolutions.get(msg.requestId);
3113
+ if (pending) pending.resolve(msg);
2264
3114
  }
2265
3115
  }
2266
3116
  if (isForkMode) process.on("message", (msg) => {
@@ -2390,16 +3240,29 @@ function createSecretMasker(request) {
2390
3240
  return masker;
2391
3241
  }
2392
3242
  /**
2393
- * Create a StepContext natively inside the workflow runner.
3243
+ * Build a fresh zx `$` shell bound to the sandbox working directory and the
3244
+ * sanitized environment (process.env was set by the parent via env-sanitizer
3245
+ * before spawning this process). This is the single shell-construction code
3246
+ * path shared by step execution (`createSandboxStepContext`) and the per-job
3247
+ * init phase (`runInitPhase`), so init commands run through the identical shell
3248
+ * steps use — same cwd, same env snapshot, same masked log streaming.
2394
3249
  *
2395
- * The context is reconstructed from the environment and IPC request fields --
2396
- * NOT serialized across the process boundary. This means zx $ runs natively
2397
- * inside this process with full shell access.
3250
+ * Intercept zx subprocess output via the log callback: zx does NOT write child
3251
+ * stdout/stderr to process.stdout it pipes to an internal VoidStream and only
3252
+ * calls $.log() with { kind: 'stdout'|'stderr' }. With verbose=false the default
3253
+ * log function skips stdout entirely, and with quiet=false it writes stderr to
3254
+ * process.stderr. We override the log function to capture both kinds directly
3255
+ * and send them as masked IPC log.line messages tagged with `stepIndex`.
3256
+ *
3257
+ * IMPORTANT: The log function must be passed in the zx$() config, not set on the
3258
+ * returned function. zx$() returns a plain function (not the proxy $), so setting
3259
+ * step$.log would only set it on the function object and NOT propagate to the
3260
+ * AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
2398
3261
  */
2399
- function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
3262
+ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
2400
3263
  let zxLineBuf = "";
2401
- const step$ = $({
2402
- cwd: workDir,
3264
+ return $({
3265
+ cwd,
2403
3266
  env: { ...process.env },
2404
3267
  verbose: false,
2405
3268
  quiet: false,
@@ -2418,20 +3281,36 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
2418
3281
  }
2419
3282
  })
2420
3283
  });
3284
+ }
3285
+ /**
3286
+ * Create a StepContext natively inside the workflow runner.
3287
+ *
3288
+ * The context is reconstructed from the environment and IPC request fields --
3289
+ * NOT serialized across the process boundary. This means zx $ runs natively
3290
+ * inside this process with full shell access.
3291
+ */
3292
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
3293
+ const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
2421
3294
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
3295
+ const rawPayload = rawPayloadFromEvent(request.event);
2422
3296
  return {
2423
3297
  $: step$,
2424
3298
  log,
2425
3299
  env: process.env,
2426
3300
  setEnv: (key, value) => {
2427
- if (operatorSecretKeys.has(key)) {
2428
- log.warn(`Cannot override operator secret "${key}" via setEnv — value preserved`);
2429
- return;
2430
- }
2431
- process.env[key] = value;
3301
+ applyEnvDelta({
3302
+ env: { [key]: value },
3303
+ pathPrepends: []
3304
+ }, {
3305
+ operatorSecretKeys,
3306
+ onReject: (k) => log.warn(`Cannot override operator secret "${k}" via setEnv — value preserved`)
3307
+ });
2432
3308
  },
2433
3309
  addPath: (dir) => {
2434
- process.env.PATH = dir + ":" + (process.env.PATH ?? "");
3310
+ applyEnvDelta({
3311
+ env: {},
3312
+ pathPrepends: [dir]
3313
+ }, { operatorSecretKeys });
2435
3314
  },
2436
3315
  inputs: {},
2437
3316
  secrets,
@@ -2442,6 +3321,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
2442
3321
  },
2443
3322
  isTestRun: request.isTestRun ?? false,
2444
3323
  environment: request.environment,
3324
+ cache: createCacheApi(workDir, buildCacheTransport()),
2445
3325
  emit: async (eventName, payload, options) => {
2446
3326
  const reqId = randomUUID();
2447
3327
  sendMessage({
@@ -2474,10 +3354,15 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
2474
3354
  });
2475
3355
  return waitForApiResponse(reqId);
2476
3356
  }),
2477
- ...request.event && { rawPayload: request.event },
3357
+ ...rawPayload && { rawPayload },
2478
3358
  ...request.provider && { provider: request.provider }
2479
3359
  };
2480
3360
  }
3361
+ /** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
3362
+ function rawPayloadFromEvent(event) {
3363
+ if (!event) return void 0;
3364
+ return event.payload ?? void 0;
3365
+ }
2481
3366
  /**
2482
3367
  * Check if a file exists at the given path.
2483
3368
  */
@@ -2499,7 +3384,8 @@ function abortAndExit(reason) {
2499
3384
  sendMessage({
2500
3385
  type: "job.complete",
2501
3386
  status: ExecutionJobStatus.enum.failed,
2502
- stepResults: []
3387
+ stepResults: [],
3388
+ ...jobTimedOut && { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${jobTimedOutMs ?? 0}ms` }
2503
3389
  });
2504
3390
  process.exit(1);
2505
3391
  }
@@ -3094,6 +3980,121 @@ function collectJobHooks(job) {
3094
3980
  return jobHooks;
3095
3981
  }
3096
3982
  /**
3983
+ * Normalize `Job.init` (config | config[] | false | undefined) to an ordered
3984
+ * array of init specs. `false` is an explicit opt-out and `undefined` (no
3985
+ * config) both resolve to an empty list — the init phase is then a no-op.
3986
+ */
3987
+ function resolveInitSpecs(job) {
3988
+ if (!job || job.init === void 0 || job.init === false) return [];
3989
+ return Array.isArray(job.init) ? [...job.init] : [job.init];
3990
+ }
3991
+ /**
3992
+ * Base stepIndex for the `init:<n>` pseudo-steps. The step loop reserves the
3993
+ * range starting at `steps.length` for hook pseudo-steps (`beforeStep` =
3994
+ * `steps.length + i*2`, `afterStep` = `steps.length + i*2 + 1`, and job-level
3995
+ * onSuccess/onFailure/cleanup from `steps.length` upward — see step-loop.ts),
3996
+ * so init indices must sit ABOVE every possible hook index to avoid collision.
3997
+ * A large fixed offset reserves a dedicated range no realistic step/hook count
3998
+ * can reach; init:<n> then occupies `INIT_STEP_INDEX_BASE + n`.
3999
+ */
4000
+ const INIT_STEP_INDEX_BASE = 1e6;
4001
+ /**
4002
+ * Read the KICI_ENV/KICI_PATH delta written by a command, apply it through
4003
+ * `applyEnvDelta` (operator-secret override guard + masked reject log), then
4004
+ * truncate the files for the next command. Shared by the per-job init phase and
4005
+ * the per-step after-hook so both honor the identical operator-secret guard.
4006
+ */
4007
+ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
4008
+ applyEnvDelta(await readEnvDelta(envFiles), {
4009
+ operatorSecretKeys,
4010
+ onReject: (key) => maskedSend({
4011
+ type: "log.line",
4012
+ stepIndex: -1,
4013
+ line: `[kici] Cannot override operator secret "${key}" via $KICI_ENV — value preserved`
4014
+ })
4015
+ });
4016
+ await truncateEnvFiles(envFiles);
4017
+ }
4018
+ /**
4019
+ * Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
4020
+ * `beforeStepEnvFiles` points the runner's process.env at the files (each step's
4021
+ * zx $ snapshots process.env at context creation, which happens AFTER this
4022
+ * before-hook, so the shell sees them; the pre-fork env allowlist does not
4023
+ * re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
4024
+ * delta, mirroring the init phase's env port.
4025
+ */
4026
+ function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
4027
+ return {
4028
+ beforeStepEnvFiles: async () => {
4029
+ process.env.KICI_ENV = envFiles.envFile;
4030
+ process.env.KICI_PATH = envFiles.pathFile;
4031
+ },
4032
+ afterStepApplyEnvFiles: async () => {
4033
+ try {
4034
+ await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
4035
+ } catch (err) {
4036
+ maskedSend({
4037
+ type: "log.line",
4038
+ stepIndex: -1,
4039
+ line: `[kici] Failed to apply $KICI_ENV/$KICI_PATH delta: ${toErrorMessage(err)}`
4040
+ });
4041
+ }
4042
+ }
4043
+ };
4044
+ }
4045
+ /**
4046
+ * Run the per-job init phase with concrete ports, then fail the job (no steps)
4047
+ * if any init spec failed or timed out.
4048
+ *
4049
+ * Concrete ports supplied to `runInitPhase`:
4050
+ * - shell: a fresh `buildSandboxShell` per init (same zx config steps use), cwd
4051
+ * = the clone root. Each init snapshots `process.env` (which `beginCapture`
4052
+ * has pointed at the shared KICI_ENV/KICI_PATH files), so the command can hand
4053
+ * env + PATH off to later inits and to every step.
4054
+ * - cache: the transport-backed `CacheApi` (`createCacheApi`) — the same engine
4055
+ * `ctx.cache` and the declarative cache phase use. Restore before / save on
4056
+ * key miss after.
4057
+ * - env: the P1 KICI_ENV/KICI_PATH lifecycle over the shared `envFiles`,
4058
+ * mirroring the step loop's `beforeStepEnvFiles` / `afterStepApplyEnvFiles`
4059
+ * (same `operatorSecretKeys` guard + masked reject log).
4060
+ *
4061
+ * On `result.ok === false`: emit `job.complete{failed}` with `stepResults: []`
4062
+ * (no step ran), an actionable error (carrying the distinct P3 timeout reason
4063
+ * when `timedOut`), and `process.exit(1)` — the step loop never executes.
4064
+ */
4065
+ async function runInitPhaseOrFailJob(args) {
4066
+ const { job, stepCwd, envFiles, operatorSecretKeys, maskedSend } = args;
4067
+ const initSpecs = resolveInitSpecs(job);
4068
+ if (initSpecs.length === 0) return;
4069
+ const initResult = await runInitPhase({
4070
+ specs: initSpecs,
4071
+ shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
4072
+ sendIpc: maskedSend,
4073
+ stepIndexBase: INIT_STEP_INDEX_BASE,
4074
+ cache: createCacheApi(stepCwd, buildCacheTransport()),
4075
+ env: {
4076
+ beginCapture: async () => {
4077
+ process.env.KICI_ENV = envFiles.envFile;
4078
+ process.env.KICI_PATH = envFiles.pathFile;
4079
+ await truncateEnvFiles(envFiles);
4080
+ },
4081
+ applyDelta: async () => {
4082
+ await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
4083
+ }
4084
+ }
4085
+ });
4086
+ if (!initResult.ok) {
4087
+ const errorBody = initResult.error ?? "";
4088
+ sendMessage({
4089
+ type: "job.complete",
4090
+ status: ExecutionJobStatus.enum.failed,
4091
+ stepResults: [],
4092
+ error: initResult.reason ? `init[${initResult.failedInitIndex}] ${initResult.reason}: ${errorBody}`.trim() : `init[${initResult.failedInitIndex}] failed: ${errorBody}`.trim()
4093
+ });
4094
+ process.exit(1);
4095
+ }
4096
+ }
4097
+ /**
3097
4098
  * Run the complete job execution lifecycle.
3098
4099
  *
3099
4100
  * 1. Receive execution request
@@ -3124,6 +4125,18 @@ async function main() {
3124
4125
  });
3125
4126
  else sendMessage(msg);
3126
4127
  };
4128
+ const jobDeadline = armJobDeadline(request.jobTimeoutMs, (reason, timeoutMs) => {
4129
+ jobTimedOut = true;
4130
+ jobTimedOutMs = timeoutMs;
4131
+ aborted = true;
4132
+ forceAborted = true;
4133
+ maskedSend({
4134
+ type: "log.line",
4135
+ stepIndex: -1,
4136
+ line: `[kici] Job exceeded its timeout of ${timeoutMs}ms (${reason}); aborting.`
4137
+ });
4138
+ jobDeadlineAbort.abort();
4139
+ });
3127
4140
  await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
3128
4141
  await applyOverlayIfRequested(request, workflowDir);
3129
4142
  if (aborted) abortAndExit("aborted after clone");
@@ -3153,6 +4166,15 @@ async function main() {
3153
4166
  flushOutputCapture();
3154
4167
  capturePrepareActive = false;
3155
4168
  const stepCwd = sourceDir;
4169
+ const envFiles = await createEnvFiles(tmpdir());
4170
+ await runInitPhaseOrFailJob({
4171
+ job,
4172
+ stepCwd,
4173
+ envFiles,
4174
+ operatorSecretKeys,
4175
+ maskedSend
4176
+ });
4177
+ const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
3156
4178
  let currentStepSecrets = null;
3157
4179
  let currentStepDispose = null;
3158
4180
  const createStepCtxWithCapture = (stepIndex, stepName) => {
@@ -3167,6 +4189,7 @@ async function main() {
3167
4189
  }
3168
4190
  return ctx;
3169
4191
  };
4192
+ const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
3170
4193
  const jobStartTime = Date.now();
3171
4194
  const loopResult = await executeStepLoop({
3172
4195
  steps: normalizedSteps,
@@ -3177,7 +4200,9 @@ async function main() {
3177
4200
  event: request.event ?? {},
3178
4201
  env: process.env,
3179
4202
  jobHooks,
4203
+ cachePhaseDeps,
3180
4204
  isAborted: () => aborted,
4205
+ jobDeadlineSignal: jobDeadlineAbort.signal,
3181
4206
  startTime: jobStartTime,
3182
4207
  getSecretsAccessLog: () => {
3183
4208
  flushOutputCapture();
@@ -3192,8 +4217,13 @@ async function main() {
3192
4217
  currentStepDispose = null;
3193
4218
  currentStepSecrets = null;
3194
4219
  if (disposeFn) await disposeFn();
3195
- }
4220
+ },
4221
+ beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
4222
+ afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
4223
+ awaitStepApproval: buildAwaitStepApproval()
3196
4224
  });
4225
+ jobDeadline.clear();
4226
+ await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
3197
4227
  let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
3198
4228
  let cancelFailureReason;
3199
4229
  if (aborted) {
@@ -3216,19 +4246,40 @@ async function main() {
3216
4246
  finalStatus = cancelResult.finalStatus;
3217
4247
  cancelFailureReason = cancelResult.cancelFailureReason;
3218
4248
  }
4249
+ if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
4250
+ emitJobComplete({
4251
+ finalStatus,
4252
+ loopResult,
4253
+ outputsMap,
4254
+ secretOutputs,
4255
+ jobTimedOut,
4256
+ jobTimeoutMs: request.jobTimeoutMs,
4257
+ cancelFailureReason,
4258
+ driftDroppedJobs
4259
+ });
4260
+ process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
4261
+ }
4262
+ /**
4263
+ * Phase 11 — emit the terminal `job.complete` IPC. Aggregates per-step outputs
4264
+ * by step name, includes encrypted secret outputs, and selects the error
4265
+ * message: a `job_timeout` reason when the job-level deadline tripped, else the
4266
+ * step-loop's failure reason (or the cancel-path's compound reason).
4267
+ */
4268
+ function emitJobComplete(args) {
3219
4269
  const aggregatedOutputs = {};
3220
- for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
4270
+ for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
3221
4271
  sendMessage({
3222
4272
  type: "job.complete",
3223
- status: finalStatus,
3224
- stepResults: loopResult.stepResults,
4273
+ status: args.finalStatus,
4274
+ stepResults: args.loopResult.stepResults,
3225
4275
  ...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
3226
- ...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
3227
- ...loopResult.failureReason && { error: loopResult.failureReason },
3228
- ...cancelFailureReason && { error: cancelFailureReason },
3229
- ...driftDroppedJobs.length > 0 && { droppedJobs: driftDroppedJobs }
4276
+ ...args.secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(args.secretOutputs) },
4277
+ ...args.jobTimedOut ? { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${args.jobTimeoutMs}ms` } : {
4278
+ ...args.loopResult.failureReason && { error: args.loopResult.failureReason },
4279
+ ...args.cancelFailureReason && { error: args.cancelFailureReason }
4280
+ },
4281
+ ...args.driftDroppedJobs.length > 0 && { droppedJobs: args.driftDroppedJobs }
3230
4282
  });
3231
- process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
3232
4283
  }
3233
4284
  /**
3234
4285
  * Find a static Job by name in the workflow.
@@ -3255,6 +4306,6 @@ main().catch((error) => {
3255
4306
  setTimeout(() => process.exit(1), 100);
3256
4307
  });
3257
4308
  //#endregion
3258
- export {};
4309
+ export { rawPayloadFromEvent, resolveInitSpecs };
3259
4310
 
3260
4311
  //# sourceMappingURL=workflow-runner.js.map