@kici-dev/agent 0.0.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +1 -6
  3. package/dist/checkout/git-clone.d.ts +59 -0
  4. package/dist/checkout/ssh-auth.d.ts +34 -0
  5. package/dist/config.d.ts +109 -0
  6. package/dist/execution/console-capture.d.ts +35 -0
  7. package/dist/execution/dep-installer.d.ts +44 -0
  8. package/dist/execution/dep-packer.d.ts +25 -0
  9. package/dist/execution/dep-restore.d.ts +85 -0
  10. package/dist/execution/download.d.ts +29 -0
  11. package/dist/execution/dynamic-job-serializer.d.ts +51 -0
  12. package/dist/execution/hook-executor.d.ts +46 -0
  13. package/dist/execution/init-runner.d.ts +33 -0
  14. package/dist/execution/job-runner.d.ts +266 -0
  15. package/dist/execution/log-streamer.d.ts +126 -0
  16. package/dist/execution/npm-registry-config.d.ts +63 -0
  17. package/dist/execution/npm-resolver.d.ts +40 -0
  18. package/dist/execution/overlay-applier.d.ts +51 -0
  19. package/dist/execution/rule-evaluator.d.ts +11 -0
  20. package/dist/execution/sandbox/bare-metal-sandbox.d.ts +69 -0
  21. package/dist/execution/sandbox/container-sandbox.d.ts +100 -0
  22. package/dist/execution/sandbox/env-sanitizer.d.ts +43 -0
  23. package/dist/execution/sandbox/firecracker-sandbox.d.ts +65 -0
  24. package/dist/execution/sandbox/fork-runner.d.ts +94 -0
  25. package/dist/execution/sandbox/index.d.ts +14 -0
  26. package/dist/execution/sandbox/ipc-protocol.d.ts +311 -0
  27. package/dist/execution/sandbox/log-masker.d.ts +45 -0
  28. package/dist/execution/sandbox/secret-encryption.d.ts +37 -0
  29. package/dist/execution/sandbox/secret-merge.d.ts +18 -0
  30. package/dist/execution/sandbox/step-loop.d.ts +77 -0
  31. package/dist/execution/sandbox/types.d.ts +142 -0
  32. package/dist/execution/sandbox/workflow-runner.d.ts +17 -0
  33. package/dist/execution/source-packer.d.ts +18 -0
  34. package/dist/execution/source-restore.d.ts +23 -0
  35. package/dist/execution/timeout-util.d.ts +11 -0
  36. package/dist/execution/workflow-loader.d.ts +70 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +128 -0
  39. package/dist/metrics/metrics-reporter.d.ts +32 -0
  40. package/dist/metrics/prometheus.d.ts +95 -0
  41. package/dist/routes/health.d.ts +27 -0
  42. package/dist/server.d.ts +20 -0
  43. package/dist/server.js +5347 -0
  44. package/dist/workflow-runner.js +2978 -0
  45. package/dist/ws/event-buffer.d.ts +16 -0
  46. package/dist/ws/log-buffer.d.ts +15 -0
  47. package/dist/ws/orchestrator-client.d.ts +269 -0
  48. package/package.json +59 -7
  49. package/sbom.spdx.json +10125 -0
  50. package/index.js +0 -3
@@ -0,0 +1,2978 @@
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
+ import { register } from "node:module";
5
+ import { createInterface } from "node:readline";
6
+ import crypto, { createHash, randomUUID } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import fsPromises, { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
9
+ import os, { tmpdir } from "node:os";
10
+ import path, { dirname, join } from "node:path";
11
+ import { $ } from "zx";
12
+ 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 "node:child_process";
16
+ import { Readable, Transform } from "node:stream";
17
+ import { pipeline } from "node:stream/promises";
18
+ import { createGunzip } from "node:zlib";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { x } from "tar";
21
+ import https from "node:https";
22
+ import http from "node:http";
23
+ import.meta.url;
24
+ //#endregion
25
+ //#region src/execution/sandbox/log-masker.ts
26
+ /**
27
+ * Secret value masking for log lines.
28
+ *
29
+ * Replaces all occurrences of registered secret values with '***' in log output.
30
+ * Used by the workflow runner to prevent secret leaks in IPC log messages.
31
+ *
32
+ * Performance: Builds a single combined regex from all secret values, so each
33
+ * log line is scanned in a single pass (not O(secrets * lines)).
34
+ */
35
+ /** Minimum length for a secret value to be maskable (avoids false positives). */
36
+ const MIN_MASK_LENGTH = 3;
37
+ /**
38
+ * Escape regex special characters in a string.
39
+ */
40
+ function escapeRegExp(s) {
41
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+ /**
44
+ * Masks secret values in log lines.
45
+ *
46
+ * Usage:
47
+ * ```ts
48
+ * const masker = new LogMasker();
49
+ * masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
50
+ * masker.mask('Token is abc123'); // 'Token is ***'
51
+ * // 'ab' is NOT masked (< 3 chars)
52
+ * ```
53
+ */
54
+ var LogMasker = class {
55
+ pattern = null;
56
+ /**
57
+ * Register secret values to be masked in log output.
58
+ *
59
+ * Values shorter than 3 characters are skipped to avoid false positives.
60
+ * Base64-encoded variants of each qualifying secret are also registered,
61
+ * preventing leaks when secrets appear base64-encoded in logs (e.g.,
62
+ * Authorization: Basic headers, base64-encoded config values).
63
+ * Values are sorted by length descending so longer values are matched first
64
+ * (prevents partial masking when one secret is a substring of another).
65
+ */
66
+ registerSecrets(secrets) {
67
+ const seen = /* @__PURE__ */ new Set();
68
+ const values = [];
69
+ for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
70
+ seen.add(value);
71
+ values.push(value);
72
+ const b64 = Buffer.from(value).toString("base64");
73
+ if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
74
+ seen.add(b64);
75
+ values.push(b64);
76
+ }
77
+ }
78
+ if (values.length === 0) {
79
+ this.pattern = null;
80
+ return;
81
+ }
82
+ values.sort((a, b) => b.length - a.length);
83
+ this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
84
+ }
85
+ /**
86
+ * Mask all registered secret values in a log line.
87
+ *
88
+ * Returns the line unchanged if no secrets are registered.
89
+ */
90
+ mask(line) {
91
+ if (!this.pattern) return line;
92
+ this.pattern.lastIndex = 0;
93
+ return line.replace(this.pattern, "***");
94
+ }
95
+ /**
96
+ * Returns true if any maskable secrets are registered.
97
+ */
98
+ hasSecrets() {
99
+ return this.pattern !== null;
100
+ }
101
+ };
102
+ //#endregion
103
+ //#region src/execution/sandbox/secret-merge.ts
104
+ /**
105
+ * Secret merging utilities for the workflow runner.
106
+ *
107
+ * Separated from workflow-runner.ts to allow unit testing without
108
+ * triggering the runner's top-level side effects (process handlers, main()).
109
+ */
110
+ /**
111
+ * Merge orchestrator-level secrets with auto-flattened context keys.
112
+ *
113
+ * Precedence (last wins):
114
+ * 1. Orchestrator-level secrets (lowest)
115
+ * 2. Context-flattened keys in declaration order (each context's keys overlay previous)
116
+ *
117
+ * This means: context-flattened keys override orchestrator-level secrets,
118
+ * and for collisions between contexts, last declared context wins.
119
+ */
120
+ function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
121
+ const merged = { ...orchestratorSecrets };
122
+ for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
123
+ return merged;
124
+ }
125
+ //#endregion
126
+ //#region src/execution/hook-executor.ts
127
+ /** Default hook timeout: 5 minutes */
128
+ const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
129
+ /**
130
+ * Build outcome metadata from execution state.
131
+ *
132
+ * Duration is calculated as elapsed time since startTime.
133
+ */
134
+ function buildOutcomeMetadata(opts) {
135
+ return {
136
+ status: opts.status,
137
+ reason: opts.reason,
138
+ failedStep: opts.failedStep,
139
+ stepOutputs: opts.stepOutputs,
140
+ duration: Date.now() - opts.startTime
141
+ };
142
+ }
143
+ /**
144
+ * Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
145
+ */
146
+ function normalizeHook(hook, hookType) {
147
+ if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
148
+ if (typeof hook === "function") return {
149
+ name: hookType,
150
+ type: hookType,
151
+ run: hook
152
+ };
153
+ if (typeof hook === "object" && "run" in hook) return {
154
+ name: hookType,
155
+ type: hookType,
156
+ run: hook.run,
157
+ timeout: hook.timeout
158
+ };
159
+ throw new Error(`Invalid hook input for ${hookType}`);
160
+ }
161
+ /**
162
+ * Execute a single hook with timeout enforcement and IPC reporting.
163
+ *
164
+ * Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
165
+ * The hook runs in the same sandbox context as regular steps.
166
+ */
167
+ async function executeHook(opts) {
168
+ const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
169
+ const normalized = normalizeHook(opts.hook, hookType);
170
+ const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
171
+ sendIpc({
172
+ type: "step.start",
173
+ stepIndex,
174
+ stepName: normalized.name,
175
+ step_type: `hook:${hookType}`
176
+ });
177
+ const startTime = Date.now();
178
+ const mergedCtx = {
179
+ ...stepContext,
180
+ outcome
181
+ };
182
+ const abortController = new AbortController();
183
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
184
+ try {
185
+ await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
186
+ abortController.signal.addEventListener("abort", () => {
187
+ reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
188
+ });
189
+ })]);
190
+ clearTimeout(timeoutId);
191
+ sendIpc({
192
+ type: "step.complete",
193
+ stepIndex,
194
+ status: "success",
195
+ durationMs: Date.now() - startTime,
196
+ step_type: `hook:${hookType}`
197
+ });
198
+ return { success: true };
199
+ } catch (e) {
200
+ clearTimeout(timeoutId);
201
+ const durationMs = Date.now() - startTime;
202
+ const error = toErrorMessage(e);
203
+ sendIpc({
204
+ type: "step.complete",
205
+ stepIndex,
206
+ status: "failed",
207
+ durationMs,
208
+ error: { message: error },
209
+ step_type: `hook:${hookType}`
210
+ });
211
+ return {
212
+ success: false,
213
+ error
214
+ };
215
+ }
216
+ }
217
+ //#endregion
218
+ //#region src/execution/rule-evaluator.ts
219
+ initZx();
220
+ /**
221
+ * Create RuleContext for agent-side rule evaluation.
222
+ *
223
+ * @param event - Event payload from the dispatch message
224
+ * @param changedFiles - List of files changed in this event
225
+ * @param env - Merged environment variables
226
+ */
227
+ function createRuleContext(event, changedFiles = [], env = {}) {
228
+ return {
229
+ event,
230
+ changedFiles,
231
+ env,
232
+ $
233
+ };
234
+ }
235
+ //#endregion
236
+ //#region src/execution/sandbox/step-loop.ts
237
+ /**
238
+ * Execute a single step with timeout enforcement.
239
+ *
240
+ * Timeout pattern using Promise.race + AbortController, with IPC status reporting.
241
+ */
242
+ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords) {
243
+ sendFn({
244
+ type: "step.start",
245
+ stepIndex,
246
+ stepName: step.name
247
+ });
248
+ const startTime = Date.now();
249
+ const abortController = new AbortController();
250
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
251
+ try {
252
+ const result = await Promise.race([step.run(ctx), new Promise((_, reject) => {
253
+ abortController.signal.addEventListener("abort", () => {
254
+ reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
255
+ });
256
+ })]);
257
+ clearTimeout(timeoutId);
258
+ const durationMs = Date.now() - startTime;
259
+ const outputsPayload = result != null ? result : void 0;
260
+ if (outputsPayload) outputsMap.set(step.name, outputsPayload);
261
+ const secretsAccessed = getSecretsAccessLog?.();
262
+ emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
263
+ sendFn({
264
+ type: "step.complete",
265
+ stepIndex,
266
+ status: ExecutionStepStatus.enum.success,
267
+ durationMs,
268
+ ...outputsPayload && { outputs: outputsPayload },
269
+ ...secretsAccessed !== void 0 && { secretsAccessed }
270
+ });
271
+ return {
272
+ name: step.name,
273
+ stepIndex,
274
+ status: ExecutionStepStatus.enum.success,
275
+ durationMs,
276
+ ...outputsPayload && { outputs: outputsPayload }
277
+ };
278
+ } catch (e) {
279
+ clearTimeout(timeoutId);
280
+ const durationMs = Date.now() - startTime;
281
+ const error = e instanceof Error ? e : new Error(String(e));
282
+ const exitCode = extractExitCode(e);
283
+ const signal = extractSignal(e);
284
+ const secretsAccessed = getSecretsAccessLog?.();
285
+ emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
286
+ sendFn({
287
+ type: "step.complete",
288
+ stepIndex,
289
+ status: ExecutionStepStatus.enum.failed,
290
+ durationMs,
291
+ error: {
292
+ message: error.message,
293
+ ...exitCode !== void 0 && { exitCode },
294
+ ...signal !== void 0 && { signal }
295
+ },
296
+ ...secretsAccessed !== void 0 && { secretsAccessed }
297
+ });
298
+ return {
299
+ name: step.name,
300
+ stepIndex,
301
+ status: ExecutionStepStatus.enum.failed,
302
+ durationMs,
303
+ error: {
304
+ message: error.message,
305
+ ...exitCode !== void 0 && { exitCode },
306
+ ...signal !== void 0 && { signal }
307
+ }
308
+ };
309
+ }
310
+ }
311
+ /**
312
+ * Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
313
+ * the step performed. Called from both the success and failure paths so the
314
+ * orchestrator's audit trail records every mount regardless of step outcome.
315
+ */
316
+ function emitSecretMountEvents(records, stepIndex, sendFn) {
317
+ if (!records || records.length === 0) return;
318
+ for (const record of records) sendFn({
319
+ type: "step.secret_mount",
320
+ stepIndex,
321
+ sources: record.sources,
322
+ target: record.target,
323
+ kind: record.kind,
324
+ ...record.envVar !== void 0 && { envVar: record.envVar }
325
+ });
326
+ }
327
+ function extractExitCode(error) {
328
+ if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
329
+ }
330
+ function extractSignal(error) {
331
+ if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
332
+ }
333
+ /**
334
+ * Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
335
+ * fails; returns null when the step should run normally.
336
+ */
337
+ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
338
+ if (!step.rules || step.rules.length === 0) return null;
339
+ const ruleCtx = createRuleContext(opts.event, [], opts.env);
340
+ const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
341
+ if (ruleResult.allPassed) return null;
342
+ opts.sendIpc({
343
+ type: "step.start",
344
+ stepIndex,
345
+ stepName: step.name
346
+ });
347
+ opts.sendIpc({
348
+ type: "step.complete",
349
+ stepIndex,
350
+ status: ExecutionStepStatus.enum.failed,
351
+ durationMs: 0
352
+ });
353
+ opts.sendIpc({
354
+ type: "log.line",
355
+ stepIndex,
356
+ line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
357
+ });
358
+ return {
359
+ name: step.name,
360
+ stepIndex,
361
+ status: ExecutionStepStatus.enum.skipped,
362
+ durationMs: 0
363
+ };
364
+ }
365
+ /**
366
+ * Run a single observer hook (beforeStep / afterStep). Failures only emit a
367
+ * log line — they never change job status. Centralises the per-call boilerplate
368
+ * so the per-step body can stay flat.
369
+ */
370
+ async function runObserverHook(args) {
371
+ const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
372
+ const outcome = buildOutcomeMetadata({
373
+ status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
374
+ stepOutputs: Object.fromEntries(opts.outputsMap),
375
+ startTime: opts.startTime ?? Date.now(),
376
+ ...failedStep !== void 0 && { failedStep }
377
+ });
378
+ const hookResult = await executeHook({
379
+ hook,
380
+ stepContext: opts.createStepContext(stepIndex, step.name),
381
+ outcome,
382
+ hookType,
383
+ stepIndex: hookStepIndex,
384
+ sendIpc: opts.sendIpc,
385
+ timeout: 3e5
386
+ });
387
+ if (!hookResult.success) opts.sendIpc({
388
+ type: "log.line",
389
+ stepIndex,
390
+ line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
391
+ });
392
+ }
393
+ /**
394
+ * Execute one iteration of the step loop: step rules → beforeStep → execute →
395
+ * afterStep → failure-handling. Returns a typed outcome the loop uses to
396
+ * accumulate results, decide whether to break, and remember the failed step.
397
+ *
398
+ * Wraps the per-step lifecycle in a `try / finally` that calls
399
+ * `opts.disposeStepResources()` so per-step state (the
400
+ * `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
401
+ * removed even when the step throws, times out, or rule-skips.
402
+ */
403
+ async function runStepIteration(step, stepIndex, opts) {
404
+ const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
405
+ if (skippedResult) {
406
+ await opts.disposeStepResources?.();
407
+ return {
408
+ result: skippedResult,
409
+ shouldBreak: false
410
+ };
411
+ }
412
+ if (opts.jobHooks?.beforeStep) await runObserverHook({
413
+ hook: opts.jobHooks.beforeStep,
414
+ hookType: "beforeStep",
415
+ step,
416
+ stepIndex,
417
+ hookStepIndex: opts.steps.length + stepIndex * 2,
418
+ opts
419
+ });
420
+ try {
421
+ const result = await executeStepInLoop(step, stepIndex, opts.createStepContext(stepIndex, step.name), step.timeout ?? opts.defaultTimeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords);
422
+ if (opts.jobHooks?.afterStep) await runObserverHook({
423
+ hook: opts.jobHooks.afterStep,
424
+ hookType: "afterStep",
425
+ step,
426
+ stepIndex,
427
+ hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
428
+ failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
429
+ opts
430
+ });
431
+ if (result.status === ExecutionStepStatus.enum.failed) return {
432
+ result,
433
+ shouldBreak: !step.continueOnError,
434
+ failedStepName: step.name
435
+ };
436
+ return {
437
+ result,
438
+ shouldBreak: false
439
+ };
440
+ } finally {
441
+ await opts.disposeStepResources?.();
442
+ }
443
+ }
444
+ /**
445
+ * Execute one named completion hook (onSuccess / onFailure / cleanup) and
446
+ * return the updated `CompletionState`. Treated as the single source of truth
447
+ * for the "promote to failed + concat reason" pattern that the three completion
448
+ * hooks share.
449
+ */
450
+ async function runCompletionHook(args) {
451
+ const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
452
+ opts.sendIpc({
453
+ type: "log.line",
454
+ stepIndex: -1,
455
+ line: `[kici] Running ${hookType} hook...`
456
+ });
457
+ const hookResult = await executeHook({
458
+ hook,
459
+ stepContext: opts.createStepContext(hookStepIndex, hookType),
460
+ outcome,
461
+ hookType,
462
+ stepIndex: hookStepIndex,
463
+ sendIpc: opts.sendIpc
464
+ });
465
+ if (hookResult.success) {
466
+ opts.sendIpc({
467
+ type: "log.line",
468
+ stepIndex: -1,
469
+ line: `[kici] ${hookType} hook completed`
470
+ });
471
+ return state;
472
+ }
473
+ opts.sendIpc({
474
+ type: "log.line",
475
+ stepIndex: -1,
476
+ line: `[kici] ${hookType} hook failed: ${hookResult.error}`
477
+ });
478
+ const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
479
+ return {
480
+ failed: state.failed || promoteToFailed,
481
+ failedStepName: state.failedStepName,
482
+ failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
483
+ };
484
+ }
485
+ /**
486
+ * Run the job-completion hook sequence after the per-step loop ends:
487
+ * onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
488
+ * recomputed so it reflects any failures introduced by onSuccess/onFailure.
489
+ */
490
+ async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
491
+ const { jobHooks } = opts;
492
+ const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
493
+ const jobOutcome = buildOutcomeMetadata({
494
+ status: initialFinalStatus,
495
+ stepOutputs: Object.fromEntries(outputsMap),
496
+ startTime,
497
+ ...initial.failedStepName && {
498
+ failedStep: initial.failedStepName,
499
+ reason: `Step '${initial.failedStepName}' failed`
500
+ }
501
+ });
502
+ let state = initial;
503
+ let hookStepIndex = opts.steps.length;
504
+ if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
505
+ state = await runCompletionHook({
506
+ hook: jobHooks.onSuccess,
507
+ hookType: "onSuccess",
508
+ hookStepIndex,
509
+ outcome: jobOutcome,
510
+ state,
511
+ promoteToFailed: true,
512
+ opts
513
+ });
514
+ hookStepIndex++;
515
+ } else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
516
+ state = await runCompletionHook({
517
+ hook: jobHooks.onFailure,
518
+ hookType: "onFailure",
519
+ hookStepIndex,
520
+ outcome: jobOutcome,
521
+ state,
522
+ promoteToFailed: false,
523
+ opts
524
+ });
525
+ hookStepIndex++;
526
+ }
527
+ if (jobHooks?.cleanup) {
528
+ const cleanupOutcome = buildOutcomeMetadata({
529
+ status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
530
+ stepOutputs: Object.fromEntries(outputsMap),
531
+ startTime,
532
+ ...state.failedStepName && { failedStep: state.failedStepName },
533
+ ...state.failureReason && { reason: state.failureReason }
534
+ });
535
+ state = await runCompletionHook({
536
+ hook: jobHooks.cleanup,
537
+ hookType: "cleanup",
538
+ hookStepIndex,
539
+ outcome: cleanupOutcome,
540
+ state,
541
+ promoteToFailed: true,
542
+ opts
543
+ });
544
+ }
545
+ return state;
546
+ }
547
+ /**
548
+ * Execute the step loop with hook integration and step-level rule evaluation.
549
+ *
550
+ * Hook execution order:
551
+ * - beforeStep -> step -> afterStep (per step)
552
+ * - onSuccess or onFailure (after all steps)
553
+ * - cleanup (always, after onSuccess/onFailure)
554
+ *
555
+ * Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
556
+ * Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
557
+ */
558
+ async function executeStepLoop(opts) {
559
+ const startTime = opts.startTime ?? Date.now();
560
+ const stepResults = [];
561
+ const state = { failed: false };
562
+ for (const [i, step] of opts.steps.entries()) {
563
+ if (opts.isAborted?.()) break;
564
+ const outcome = await runStepIteration(step, i, opts);
565
+ stepResults.push(outcome.result);
566
+ if (outcome.failedStepName) {
567
+ state.failed = true;
568
+ state.failedStepName = outcome.failedStepName;
569
+ }
570
+ if (outcome.shouldBreak) break;
571
+ }
572
+ if (opts.isAborted?.()) return {
573
+ status: "aborted",
574
+ stepResults,
575
+ failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
576
+ };
577
+ const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
578
+ return {
579
+ status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
580
+ stepResults,
581
+ failureReason: finalState.failureReason
582
+ };
583
+ }
584
+ //#endregion
585
+ //#region src/checkout/ssh-auth.ts
586
+ /**
587
+ * Materialize an SSH private key (and optional pinned known_hosts) into a
588
+ * tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
589
+ *
590
+ * Permissions:
591
+ * - private key mode 0o600 (required by OpenSSH — refuses to use world-
592
+ * readable keys).
593
+ * - known_hosts mode 0o600.
594
+ * - tempdir mode 0o700.
595
+ *
596
+ * SSH flags composed:
597
+ * - `-i <keyfile>` — identity file.
598
+ * - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
599
+ * - `-o BatchMode=yes` — never prompt for passwords / passphrases.
600
+ * - host-key checking flags based on `hostKeyPolicy`.
601
+ */
602
+ async function setupSshAuth(opts) {
603
+ if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
604
+ const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
605
+ const keyPath = join(tempDir, "id");
606
+ await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
607
+ const knownHostsPath = join(tempDir, "known_hosts");
608
+ await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
609
+ const parts = [
610
+ "ssh",
611
+ "-i",
612
+ escapeShellArg(keyPath),
613
+ "-o",
614
+ "IdentitiesOnly=yes",
615
+ "-o",
616
+ "BatchMode=yes",
617
+ "-o",
618
+ `UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
619
+ ];
620
+ if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
621
+ else parts.push("-o", "StrictHostKeyChecking=accept-new");
622
+ return {
623
+ gitSshCommand: parts.join(" "),
624
+ tempDir,
625
+ async cleanup() {
626
+ await rm(tempDir, {
627
+ recursive: true,
628
+ force: true
629
+ });
630
+ }
631
+ };
632
+ }
633
+ /**
634
+ * Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
635
+ * wrapping so backslashes and spaces survive git's shell-parse of the
636
+ * command value.
637
+ */
638
+ function escapeShellArg(value) {
639
+ return `'${value.replace(/'/g, "'\\''")}'`;
640
+ }
641
+ //#endregion
642
+ //#region src/checkout/git-clone.ts
643
+ /**
644
+ * Strip auth credentials from git error messages to prevent token leakage.
645
+ * Node's execFileSync includes the full command line (including -c http.extraHeader
646
+ * and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
647
+ * tokens or the absolute path of a temporary SSH key file in logs.
648
+ */
649
+ function sanitizeGitError(error) {
650
+ if (!(error instanceof Error)) return new Error(String(error));
651
+ const sanitized = new Error(redactSensitive(error.message));
652
+ sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
653
+ return sanitized;
654
+ }
655
+ function redactSensitive(input) {
656
+ 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]");
657
+ }
658
+ /**
659
+ * Shallow-clone a git repository at a specific ref with optional token auth.
660
+ *
661
+ * Token authentication uses git's `-c http.extraHeader` mechanism which keeps
662
+ * the token out of the clone URL (not visible in `git remote -v` or logs).
663
+ *
664
+ * After clone, verifies that HEAD matches the expected SHA to prevent
665
+ * wrong-ref execution.
666
+ *
667
+ * @throws Error if clone fails or SHA does not match
668
+ */
669
+ async function gitClone(options) {
670
+ const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
671
+ const auth = gitAuth ? gitAuth : token ? {
672
+ kind: "basic",
673
+ user: "x-access-token",
674
+ secret: token
675
+ } : void 0;
676
+ const args = [];
677
+ const envEntries = {};
678
+ let needsCustomEnv = false;
679
+ let safeDirCleanup;
680
+ if (repoUrl.startsWith("file://")) {
681
+ const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
682
+ const { tmpdir } = await import("node:os");
683
+ const path = await import("node:path");
684
+ const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
685
+ const cfgPath = path.join(dir, "config");
686
+ await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
687
+ envEntries.GIT_CONFIG_GLOBAL = cfgPath;
688
+ needsCustomEnv = true;
689
+ safeDirCleanup = async () => {
690
+ await rm(dir, {
691
+ recursive: true,
692
+ force: true
693
+ }).catch(() => {});
694
+ };
695
+ }
696
+ let sshSetup;
697
+ try {
698
+ if (auth?.kind === "basic") {
699
+ const user = auth.user ?? "x-access-token";
700
+ const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
701
+ args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
702
+ } else if (auth?.kind === "ssh") {
703
+ sshSetup = await setupSshAuth({
704
+ privateKey: auth.secret,
705
+ hostKeyPolicy: auth.sshHostKeyPolicy,
706
+ knownHosts: auth.sshKnownHostsPem
707
+ });
708
+ envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
709
+ needsCustomEnv = true;
710
+ }
711
+ const env = needsCustomEnv ? {
712
+ ...process.env,
713
+ ...envEntries
714
+ } : void 0;
715
+ if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
716
+ else args.push("clone", "--depth", String(depth), repoUrl, workDir);
717
+ const { execFileSync } = await import("node:child_process");
718
+ try {
719
+ execFileSync("git", args, {
720
+ stdio: "pipe",
721
+ timeout: 12e4,
722
+ ...env && { env }
723
+ });
724
+ } catch (err) {
725
+ throw sanitizeGitError(err);
726
+ }
727
+ if (!sha || sha === "HEAD") return;
728
+ const envOpts = env ? { env } : {};
729
+ if (!execFileSync("git", [
730
+ "-C",
731
+ workDir,
732
+ "rev-parse",
733
+ "HEAD"
734
+ ], {
735
+ encoding: "utf-8",
736
+ timeout: 1e4,
737
+ ...envOpts
738
+ }).trim().startsWith(sha)) {
739
+ const fetchArgs = [];
740
+ if (auth?.kind === "basic") {
741
+ const user = auth.user ?? "x-access-token";
742
+ const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
743
+ fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
744
+ }
745
+ fetchArgs.push("fetch", "--depth", "50", "origin", sha);
746
+ try {
747
+ execFileSync("git", [
748
+ "-C",
749
+ workDir,
750
+ ...fetchArgs
751
+ ], {
752
+ stdio: "pipe",
753
+ timeout: 12e4,
754
+ ...envOpts
755
+ });
756
+ } catch (err) {
757
+ throw sanitizeGitError(err);
758
+ }
759
+ execFileSync("git", [
760
+ "-C",
761
+ workDir,
762
+ "checkout",
763
+ sha
764
+ ], {
765
+ stdio: "pipe",
766
+ timeout: 3e4,
767
+ ...envOpts
768
+ });
769
+ const recheckedSha = execFileSync("git", [
770
+ "-C",
771
+ workDir,
772
+ "rev-parse",
773
+ "HEAD"
774
+ ], {
775
+ encoding: "utf-8",
776
+ timeout: 1e4,
777
+ ...envOpts
778
+ }).trim();
779
+ if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
780
+ }
781
+ } finally {
782
+ if (sshSetup) await sshSetup.cleanup().catch(() => {});
783
+ if (safeDirCleanup) await safeDirCleanup();
784
+ }
785
+ }
786
+ //#endregion
787
+ //#region src/execution/dep-restore.ts
788
+ /**
789
+ * Dependency restoration from cached tarballs.
790
+ *
791
+ * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
792
+ * and extracts to .kici/node_modules/ in the work directory.
793
+ *
794
+ * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
795
+ * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
796
+ * file:// URLs use a buffer-based approach (local, no streaming benefit).
797
+ *
798
+ * Streaming downloads have a 5-minute timeout and up to 2 retries.
799
+ */
800
+ const logger$3 = createLogger({ prefix: "dep-restore" });
801
+ /** Download timeout: 5 minutes. */
802
+ const DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
803
+ /**
804
+ * Compute SHA-256 hash of a buffer.
805
+ */
806
+ function computeHash(data) {
807
+ return sha256(data);
808
+ }
809
+ /**
810
+ * Extract a gzip tarball from a buffer into the target directory.
811
+ * Used for file:// URLs where streaming provides no benefit.
812
+ */
813
+ async function extractTarball(data, targetDir) {
814
+ await mkdir(targetDir, { recursive: true });
815
+ const readable = Readable.from(data);
816
+ await new Promise((resolve, reject) => {
817
+ readable.pipe(x({
818
+ cwd: targetDir,
819
+ gzip: true
820
+ })).on("finish", resolve).on("error", reject);
821
+ });
822
+ }
823
+ /**
824
+ * Stream download and extract an HTTP/HTTPS tarball.
825
+ *
826
+ * Computes SHA-256 hash on the fly via a Transform stream.
827
+ * Returns the computed hash of the compressed tarball data.
828
+ */
829
+ async function streamFetchAndExtract(url, targetDir) {
830
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
831
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
832
+ if (!response.body) throw new Error("No response body");
833
+ const nodeStream = Readable.fromWeb(response.body);
834
+ const hash = createHash("sha256");
835
+ const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
836
+ hash.update(chunk);
837
+ callback(null, chunk);
838
+ } });
839
+ await mkdir(targetDir, { recursive: true });
840
+ await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
841
+ return hash.digest("hex");
842
+ }
843
+ /**
844
+ * Path-relative-to-`.kici/` glob that matches every scratch dir
845
+ * `extractIntoScratch` may create. Surfaced so the clone phase can register
846
+ * it in `.git/info/exclude` (see `excludeScratchFromGit`) — keeping the glob
847
+ * and the exclude rule in the same file means future renames of the scratch
848
+ * dir prefix can't fall out of sync with the git-ignore wiring.
849
+ */
850
+ const SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
851
+ /**
852
+ * Glob suitable for `.gitignore` / `.git/info/exclude` that matches every
853
+ * scratch dir created by `extractIntoScratch`, anchored to the workflow
854
+ * working tree's `.kici/` subdir.
855
+ */
856
+ const SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
857
+ /**
858
+ * Extract the dep tarball into a per-attempt scratch dir so retries never race
859
+ * with still-draining I/O from a previous failed attempt.
860
+ *
861
+ * When `pipeline()` rejects on a network error or AbortSignal timeout, the
862
+ * underlying `tar.x` continues flushing pending file writes for an unbounded
863
+ * window after the promise settles — `pipeline` does not block on async
864
+ * filesystem side effects. If the next retry then runs `rm -rf` on the same
865
+ * `node_modules/`, the walk races with those writes and `rmdir` fails with
866
+ * ENOTEMPTY (new files keep appearing under a directory we just emptied).
867
+ *
868
+ * We sidestep the race entirely by extracting each attempt into a unique
869
+ * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
870
+ * draining writes are harmless — the next attempt does not touch them. On
871
+ * success we `fs.rename` `${scratchDir}/node_modules` into the final location
872
+ * (atomic on the same filesystem), then best-effort clean the scratch dir.
873
+ *
874
+ * Scratch dirs land inside the customer's cloned working tree, so the clone
875
+ * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
876
+ * keep them out of `git status` for any workflow step that shells out to git.
877
+ * See `excludeScratchFromGit`.
878
+ */
879
+ async function extractIntoScratch(url, kiciDir, attempt) {
880
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
881
+ await mkdir(scratchDir, { recursive: true });
882
+ return {
883
+ scratchDir,
884
+ hash: await streamFetchAndExtract(url, scratchDir)
885
+ };
886
+ }
887
+ /**
888
+ * Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
889
+ * so any in-flight or orphaned dep-restore scratch dirs are invisible to
890
+ * `git status` / `git add` inside the customer's cloned working tree.
891
+ *
892
+ * Why `.git/info/exclude` and not `.gitignore`:
893
+ * - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
894
+ * modify it. Doing so would surface the rule in their PRs and create a
895
+ * diff customers never asked for.
896
+ * - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
897
+ * mechanism for "ignore these patterns in THIS working tree". Git creates
898
+ * an empty (template-commented) file on `git init` / `git clone`, so it
899
+ * already exists by the time we're called.
900
+ *
901
+ * Why this lives next to `extractIntoScratch`:
902
+ * - The exclude glob is tied 1:1 to the scratch dir naming convention. If
903
+ * the prefix ever changes, the rule must change too. Defining both in the
904
+ * same file means a rename touches one place, not two.
905
+ *
906
+ * Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
907
+ * `git clone` and the dir layout differs) we log and continue — failing the
908
+ * job over a missing git ignore wiring would be worse than the cosmetic
909
+ * issue we're solving.
910
+ *
911
+ * Idempotent: callers may invoke this multiple times (dual-clone path, retry
912
+ * after partial setup). We skip the append if the glob is already present.
913
+ *
914
+ * @param repoWorkDir - The git working tree root (the dir that contains
915
+ * `.git/`). For normal workflows this is the agent's job workDir; for
916
+ * global workflows it is the workflow repo dir (whose `.kici/` carries
917
+ * the scratch dirs).
918
+ */
919
+ async function excludeScratchFromGit(repoWorkDir) {
920
+ const excludePath = join(repoWorkDir, ".git", "info", "exclude");
921
+ try {
922
+ const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
923
+ if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
924
+ const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
925
+ await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
926
+ } catch (err) {
927
+ logger$3.warn("Failed to register scratch dir glob in .git/info/exclude", {
928
+ excludePath,
929
+ error: err instanceof Error ? err.message : String(err)
930
+ });
931
+ }
932
+ }
933
+ /**
934
+ * Rewrite localhost URLs to use the orchestrator host.
935
+ *
936
+ * The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
937
+ * but agent containers can't reach localhost. This utility replaces the
938
+ * host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
939
+ */
940
+ function resolveOrchestratorUrl(url) {
941
+ if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
942
+ const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
943
+ if (!orchestratorUrl) return url;
944
+ try {
945
+ const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
946
+ const parsed = new URL(url);
947
+ parsed.hostname = orchestratorParsed.hostname;
948
+ return parsed.toString();
949
+ } catch {
950
+ return url;
951
+ }
952
+ }
953
+ /**
954
+ * Restore dependencies from a cached tarball.
955
+ *
956
+ * For HTTP/HTTPS URLs: uses a streaming pipeline (response -> hash -> gunzip -> tar)
957
+ * with a 5-minute timeout and up to 2 retries. This avoids buffering entire tarballs
958
+ * in memory, eliminating memory spikes proportional to tarball size.
959
+ *
960
+ * For file:// URLs: uses a buffer-based approach (local, no streaming benefit).
961
+ *
962
+ * @param workDir - Root directory of the cloned repository
963
+ * @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
964
+ * @param depsHash - Optional expected SHA-256 hash of the tarball
965
+ */
966
+ async function restoreDeps(workDir, depsUrl, depsHash) {
967
+ depsUrl = resolveOrchestratorUrl(depsUrl);
968
+ logger$3.info("Downloading dependency tarball", { url: depsUrl });
969
+ const kiciDir = join(workDir, ".kici");
970
+ if (depsUrl.startsWith("file://")) {
971
+ const localPath = fileURLToPath(depsUrl);
972
+ const data = await fsPromises.readFile(localPath);
973
+ if (depsHash) {
974
+ const actualHash = computeHash(data);
975
+ if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
976
+ }
977
+ await extractTarball(data, kiciDir);
978
+ const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
979
+ logger$3.info("Dependencies restored from cache (file)", {
980
+ sizeMB,
981
+ targetDir: kiciDir
982
+ });
983
+ return;
984
+ }
985
+ if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
986
+ let lastError;
987
+ for (let attempt = 0; attempt <= 2; attempt++) {
988
+ if (attempt > 0) logger$3.warn("Retrying dep tarball download", {
989
+ attempt,
990
+ url: depsUrl
991
+ });
992
+ try {
993
+ const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
994
+ if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
995
+ await fsPromises.rename(join(scratchDir, "node_modules"), join(kiciDir, "node_modules"));
996
+ try {
997
+ await fsPromises.rm(scratchDir, {
998
+ recursive: true,
999
+ force: true
1000
+ });
1001
+ } catch (cleanupErr) {
1002
+ logger$3.warn("Scratch dir cleanup failed (orphan left behind)", {
1003
+ scratchDir,
1004
+ error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
1005
+ });
1006
+ }
1007
+ logger$3.info("Dependencies restored from cache (stream)", { targetDir: kiciDir });
1008
+ return;
1009
+ } catch (err) {
1010
+ lastError = err instanceof Error ? err : new Error(String(err));
1011
+ logger$3.warn("Dep tarball download failed", {
1012
+ attempt,
1013
+ error: lastError.message
1014
+ });
1015
+ }
1016
+ }
1017
+ throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
1018
+ }
1019
+ //#endregion
1020
+ //#region src/execution/npm-resolver.ts
1021
+ /**
1022
+ * Resolve the npm CLI path relative to the running Node.js binary.
1023
+ *
1024
+ * Used both at startup (builder role readiness check) and at install time
1025
+ * (dep-installer). Centralizes the resolution logic so it stays consistent.
1026
+ *
1027
+ * Resolution strategy:
1028
+ * 1. Check standard Node.js layout paths relative to process.execPath
1029
+ * 2. Fall back to bare 'npm' on PATH (development environments)
1030
+ */
1031
+ /**
1032
+ * Resolve the npm CLI path from the current Node.js binary.
1033
+ *
1034
+ * Checks standard Node.js distribution layout paths:
1035
+ * - {nodeDir}/../lib/node_modules/npm/bin/npm-cli.js (Linux/macOS installed)
1036
+ * - {nodeDir}/node_modules/npm/bin/npm-cli.js (Windows / some layouts)
1037
+ *
1038
+ * Returns undefined npmCliPath if neither is found (caller can fall back to PATH).
1039
+ */
1040
+ function resolveNpm() {
1041
+ const nodeExe = process.execPath;
1042
+ const nodeDir = dirname(nodeExe);
1043
+ return {
1044
+ npmCliPath: [join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")].find((p) => existsSync(p)),
1045
+ nodeExe,
1046
+ nodeDir
1047
+ };
1048
+ }
1049
+ //#endregion
1050
+ //#region src/execution/npm-registry-config.ts
1051
+ /**
1052
+ * Apply private-npm-registry auth to a workflow's `.kici/.npmrc` for the
1053
+ * lifetime of one `npm install` invocation, then restore the file on cleanup.
1054
+ *
1055
+ * Why a closure-cleanup pattern (mirrors `setupSshAuth.cleanup()` in
1056
+ * `packages/agent/src/checkout/git-clone.ts`): the customer's committed
1057
+ * `.kici/.npmrc` may carry literal `${VAR}` placeholders or an unrelated
1058
+ * scope mapping. We must NOT clobber it permanently — we just want to
1059
+ * append the agent-managed registry/auth lines for this single install,
1060
+ * then revert.
1061
+ *
1062
+ * Token bytes never end up in the on-disk `.npmrc`. Each registry's token
1063
+ * is exposed as a job-scoped env var (`KICI_NPM_TOKEN_${jobIdShort}_<i>`)
1064
+ * and the on-disk auth line carries the env var reference (`${VAR}`). npm
1065
+ * substitutes at read time. The job-scoped nonce makes the env var name
1066
+ * unguessable from outside the install subprocess.
1067
+ *
1068
+ * Merge order: customer-committed `.npmrc` lines come FIRST, agent-generated
1069
+ * lines come LAST. npm's last-wins semantics make the agent's line shadow
1070
+ * any literal `_authToken=...` the customer accidentally committed for a
1071
+ * registry KiCI manages — refuses to let a committed secret beat a managed
1072
+ * one.
1073
+ *
1074
+ * `installEnvSecrets` is a separate channel for customers who prefer the
1075
+ * "commit a `.kici/.npmrc` with `${MY_TOKEN}` and supply MY_TOKEN as a
1076
+ * scoped secret" pattern (Option C in the design doc). Each entry becomes
1077
+ * an env var on the install subprocess; the customer's existing `.npmrc`
1078
+ * uses it as `${MY_TOKEN}`.
1079
+ */
1080
+ /** No-op result returned when nothing needs to be applied. */
1081
+ function noopResult() {
1082
+ return {
1083
+ extraEnv: {},
1084
+ tokensForRedaction: [],
1085
+ cleanup: async () => {}
1086
+ };
1087
+ }
1088
+ /** Build the synthesized env-var name for registry index `i`. */
1089
+ function tokenEnvName(jobIdShort, index) {
1090
+ return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
1091
+ }
1092
+ /** Render the agent-managed block of `.npmrc` lines. */
1093
+ function renderAgentLines(registries, jobIdShort) {
1094
+ if (registries.length === 0) return "";
1095
+ const lines = [];
1096
+ for (let i = 0; i < registries.length; i++) {
1097
+ const reg = registries[i];
1098
+ const envVar = tokenEnvName(jobIdShort, i);
1099
+ const authKey = reg.url.replace(/^https?:/, "");
1100
+ if (reg.scope) lines.push(`${reg.scope}:registry=${reg.url}`);
1101
+ else lines.push(`registry=${reg.url}`);
1102
+ lines.push(`${authKey}:_authToken=\${${envVar}}`);
1103
+ if (reg.alwaysAuth) lines.push(`${authKey}:always-auth=true`);
1104
+ }
1105
+ return `# kici-managed: applied for one npm install only\n${lines.join("\n")}\n`;
1106
+ }
1107
+ /** Read original `.npmrc` bytes; null if the file does not exist. */
1108
+ async function readOriginalNpmrc(npmrcPath) {
1109
+ try {
1110
+ return await readFile(npmrcPath, "utf8");
1111
+ } catch (err) {
1112
+ if (err.code === "ENOENT") return null;
1113
+ throw err;
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Apply the merged `.npmrc` and return env + redaction + cleanup. Caller
1118
+ * runs the npm install with `extraEnv` merged in, then awaits cleanup()
1119
+ * inside the install's `finally`.
1120
+ */
1121
+ async function applyNpmRegistryConfig(args) {
1122
+ const registries = args.npmRegistries ?? [];
1123
+ const installEnvSecrets = args.installEnvSecrets ?? {};
1124
+ if (registries.length === 0 && Object.keys(installEnvSecrets).length === 0) return noopResult();
1125
+ const npmrcPath = join(args.kiciDir, ".npmrc");
1126
+ const original = await readOriginalNpmrc(npmrcPath);
1127
+ const tokenEnv = {};
1128
+ const tokensForRedaction = [];
1129
+ for (let i = 0; i < registries.length; i++) {
1130
+ tokenEnv[tokenEnvName(args.jobIdShort, i)] = registries[i].token;
1131
+ tokensForRedaction.push(registries[i].token);
1132
+ }
1133
+ for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
1134
+ const agentBlock = renderAgentLines(registries, args.jobIdShort);
1135
+ const merged = `${original ?? ""}${original && !original.endsWith("\n") ? "\n" : ""}${agentBlock}`;
1136
+ if (agentBlock.length > 0) await writeFile(npmrcPath, merged, {
1137
+ encoding: "utf8",
1138
+ mode: 384
1139
+ });
1140
+ const cleanup = async () => {
1141
+ if (agentBlock.length === 0) return;
1142
+ try {
1143
+ if (original === null) await unlink(npmrcPath).catch(() => {});
1144
+ else await writeFile(npmrcPath, original, { encoding: "utf8" });
1145
+ } catch {}
1146
+ };
1147
+ return {
1148
+ extraEnv: {
1149
+ ...installEnvSecrets,
1150
+ ...tokenEnv
1151
+ },
1152
+ tokensForRedaction,
1153
+ cleanup
1154
+ };
1155
+ }
1156
+ /** Mask every token in `tokensForRedaction` out of `input` before logging. */
1157
+ function redactNpmOutput(input, tokens) {
1158
+ if (!input) return input;
1159
+ let out = input;
1160
+ for (const token of tokens) {
1161
+ if (!token) continue;
1162
+ out = out.split(token).join("***REDACTED***");
1163
+ }
1164
+ return out;
1165
+ }
1166
+ //#endregion
1167
+ //#region src/execution/dep-installer.ts
1168
+ /**
1169
+ * Inline dependency installation for graceful degradation.
1170
+ *
1171
+ * When dep cache is unavailable or download fails, the agent falls back
1172
+ * to running npm install directly.
1173
+ *
1174
+ * Only npm is supported — it ships with every Node.js installation.
1175
+ * .kici/package.json is required — its presence signals deps should be installed.
1176
+ *
1177
+ * Security: npm runs with an isolated per-invocation cache directory to prevent
1178
+ * cache poisoning across build jobs. A malicious package.json in one repo cannot
1179
+ * taint the cache used by subsequent builds. The same pressure rules out letting
1180
+ * lifecycle scripts see synthesized auth env vars — npm runs with
1181
+ * `--ignore-scripts` whenever a private registry is configured.
1182
+ */
1183
+ const logger$2 = createLogger({ prefix: "dep-installer" });
1184
+ /**
1185
+ * Install dependencies inline using npm.
1186
+ *
1187
+ * Falls back to this when the dep cache is unavailable or download fails.
1188
+ *
1189
+ * npm runs with an isolated cache directory (created in os.tmpdir()) to prevent
1190
+ * cache poisoning between build jobs. The cache is removed after installation.
1191
+ *
1192
+ * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, the helper
1193
+ * synthesizes a job-scoped `.kici/.npmrc` overlay for the install, restores
1194
+ * the original file in `finally`, and runs npm with `--ignore-scripts` so
1195
+ * lifecycle scripts in committed `package.json` cannot exfiltrate the
1196
+ * synthesized token env vars.
1197
+ *
1198
+ * @param kiciDir - Path to the .kici/ directory containing package.json
1199
+ * @param opts - Optional registry / installEnv configuration. When absent,
1200
+ * behavior is identical to the pre-private-registry version.
1201
+ */
1202
+ async function installDeps(kiciDir, opts = {}) {
1203
+ logger$2.info("Installing deps inline", {
1204
+ packageManager: "npm",
1205
+ dir: kiciDir
1206
+ });
1207
+ process.stderr.write(`[dep-installer:trace] starting install: pm=npm, cwd=${kiciDir}\n`);
1208
+ const startTime = Date.now();
1209
+ const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
1210
+ const registryConfig = await applyNpmRegistryConfig({
1211
+ kiciDir,
1212
+ npmRegistries: opts.npmRegistries,
1213
+ installEnvSecrets: opts.installEnvSecrets,
1214
+ jobIdShort: opts.jobIdShort ?? "00000000"
1215
+ });
1216
+ try {
1217
+ const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
1218
+ const npmCmd = "install";
1219
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
1220
+ const { NODE_ENV: _, ...restEnv } = process.env;
1221
+ const envWithNode = {
1222
+ ...restEnv,
1223
+ ...registryConfig.extraEnv,
1224
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
1225
+ };
1226
+ const { execFileSync: execFile } = await import("node:child_process");
1227
+ const buildArgs = (...prefix) => {
1228
+ const args = [
1229
+ ...prefix,
1230
+ npmCmd,
1231
+ "--cache",
1232
+ cacheDir
1233
+ ];
1234
+ if (hasPrivateRegistry) args.push("--ignore-scripts");
1235
+ return args;
1236
+ };
1237
+ try {
1238
+ if (npmCliPath) {
1239
+ process.stderr.write(`[dep-installer:trace] running: ${nodeExe} ${npmCliPath} ${npmCmd} --cache ${cacheDir}${hasPrivateRegistry ? " --ignore-scripts" : ""}\n`);
1240
+ execFile(nodeExe, buildArgs(npmCliPath), {
1241
+ cwd: kiciDir,
1242
+ env: envWithNode,
1243
+ timeout: 6e5,
1244
+ stdio: "pipe"
1245
+ });
1246
+ } else {
1247
+ process.stderr.write(`[dep-installer:trace] running: npm ${npmCmd} --cache ${cacheDir}${hasPrivateRegistry ? " --ignore-scripts" : ""}\n`);
1248
+ execFile("npm", buildArgs(), {
1249
+ cwd: kiciDir,
1250
+ env: envWithNode,
1251
+ timeout: 6e5,
1252
+ stdio: "pipe"
1253
+ });
1254
+ }
1255
+ } finally {
1256
+ await rm(cacheDir, {
1257
+ recursive: true,
1258
+ force: true
1259
+ }).catch(() => {});
1260
+ }
1261
+ } catch (e) {
1262
+ const msg = toErrorMessage(e);
1263
+ const tokens = registryConfig.tokensForRedaction;
1264
+ process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(msg, tokens)}\n`);
1265
+ if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
1266
+ if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
1267
+ throw e;
1268
+ } finally {
1269
+ await registryConfig.cleanup();
1270
+ }
1271
+ const durationMs = Date.now() - startTime;
1272
+ process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
1273
+ logger$2.info("Deps installed inline", {
1274
+ packageManager: "npm",
1275
+ durationMs
1276
+ });
1277
+ }
1278
+ //#endregion
1279
+ //#region src/execution/workflow-loader.ts
1280
+ /**
1281
+ * Workflow module loading: transforms `.ts` workflow files on import via the
1282
+ * shared oxc-transform ESM loader hook. Customer workflow code is imported
1283
+ * directly from the cloned / extracted source tree — no intermediate bundle,
1284
+ * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
1285
+ * Node's normal ESM lookup against `.kici/node_modules/`.
1286
+ */
1287
+ const AGENT_SDK_VERSION = "0.1.1";
1288
+ const AGENT_SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
1289
+ /**
1290
+ * Register the shared oxc-transform ESM loader hook so subsequent dynamic
1291
+ * `import()` calls for `.ts` / `.tsx` files transform on the fly. Idempotent
1292
+ * at our level via the `hookRegistered` flag; Node also tolerates repeated
1293
+ * `register()` calls by stacking layers, but we avoid the noise.
1294
+ */
1295
+ let hookRegistered = false;
1296
+ function ensureLoaderHookRegistered() {
1297
+ if (hookRegistered) return;
1298
+ register("@kici-dev/shared/ts-loader-hook", import.meta.url);
1299
+ hookRegistered = true;
1300
+ }
1301
+ /**
1302
+ * Compute content hash for a workflow (same formula as `@kici-dev/compiler`
1303
+ * lockfile/hasher.ts). Used to verify the loaded source matches the lock
1304
+ * file's contentHash when `expectedContentHash` is provided.
1305
+ *
1306
+ * Line endings in `rawSource` (and inside `assetDigest`) are normalized to LF
1307
+ * before hashing. This matches the compiler-side normalization in
1308
+ * `@kici-dev/compiler` `lockfile/hasher.ts` so Windows agents — where Git's
1309
+ * `core.autocrlf=true` system default checks out text files with CRLF — agree
1310
+ * with lockfiles compiled on Linux (LF).
1311
+ */
1312
+ function computeContentHash(rawSource, assetDigest) {
1313
+ let input = `5:${normalizeLineEndings(rawSource)}`;
1314
+ if (assetDigest !== void 0 && assetDigest.length > 0) input += `\0${normalizeLineEndings(assetDigest)}`;
1315
+ return sha256(input);
1316
+ }
1317
+ async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
1318
+ const parts = [];
1319
+ for (const rel of resolvedPaths) {
1320
+ const abs = path.join(workDir, rel);
1321
+ try {
1322
+ const content = await fsPromises.readFile(abs, "utf-8");
1323
+ parts.push(`${rel}\n${content}`);
1324
+ } catch {
1325
+ parts.push(`${rel}\n`);
1326
+ }
1327
+ }
1328
+ return parts.join("");
1329
+ }
1330
+ /**
1331
+ * Load a workflow module by dynamic-importing its source file.
1332
+ *
1333
+ * Registers the oxc-transform loader hook (idempotent), then dynamic-imports
1334
+ * the `.ts` file. Transitive imports resolve against the workspace's
1335
+ * `node_modules/` the same way any `tsx`-style runner would — so host-repo
1336
+ * helpers and `@kici-dev/sdk` Just Work.
1337
+ *
1338
+ * When `expectedContentHash` is provided, verifies the raw source matches
1339
+ * the hash in the lock file. Drift between source and lock file produces a
1340
+ * descriptive error that surfaces the baked agent SDK fingerprint (useful
1341
+ * when debugging "is the agent running a stale build?").
1342
+ */
1343
+ async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, resolvedHashFiles) {
1344
+ ensureLoaderHookRegistered();
1345
+ const filePath = path.join(workDir, sourceFile);
1346
+ if (expectedContentHash) {
1347
+ const rawSource = await fsPromises.readFile(filePath, "utf-8");
1348
+ let assetDigest;
1349
+ if (resolvedHashFiles?.length) assetDigest = await buildAssetDigestFromResolvedPaths(workDir, resolvedHashFiles);
1350
+ const actualHash = computeContentHash(rawSource, assetDigest);
1351
+ if (actualHash !== expectedContentHash) throw new Error(`Lock file is out of date: workflow source changed without regenerating kici.lock.json (expected contentHash ${expectedContentHash}, got ${actualHash}, agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}). Run 'kici compile' and commit the updated lock file.`);
1352
+ }
1353
+ return { module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`) };
1354
+ }
1355
+ /**
1356
+ * Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
1357
+ */
1358
+ function isWorkflow(value) {
1359
+ return typeof value === "object" && value !== null && "_tag" in value && value._tag === "Workflow";
1360
+ }
1361
+ /**
1362
+ * Extract a workflow by name from a module's exports.
1363
+ *
1364
+ * Searches:
1365
+ * 1. Default export (single Workflow or array of Workflows)
1366
+ * 2. Named exports
1367
+ */
1368
+ function extractWorkflow(module, workflowName) {
1369
+ if (module.default) {
1370
+ const defaultExport = module.default;
1371
+ if (isWorkflow(defaultExport) && defaultExport.name === workflowName) return defaultExport;
1372
+ if (Array.isArray(defaultExport)) {
1373
+ const found = defaultExport.find((item) => isWorkflow(item) && item.name === workflowName);
1374
+ if (found) return found;
1375
+ }
1376
+ }
1377
+ for (const [, value] of Object.entries(module)) if (isWorkflow(value) && value.name === workflowName) return value;
1378
+ throw new Error(`Workflow '${workflowName}' not found in module exports`);
1379
+ }
1380
+ /**
1381
+ * Extract a dynamic job function from a workflow by index.
1382
+ */
1383
+ function extractDynamicJobFn(workflow, index) {
1384
+ if (index < 0 || index >= workflow.jobs.length) throw new Error(`Job index ${index} out of bounds (workflow '${workflow.name}' has ${workflow.jobs.length} jobs)`);
1385
+ const item = workflow.jobs[index];
1386
+ if (!isDynamicJobFn(item)) throw new Error(`Job at index ${index} in workflow '${workflow.name}' is not a dynamic job fn`);
1387
+ return item;
1388
+ }
1389
+ /**
1390
+ * Extract steps from a static job within a workflow.
1391
+ */
1392
+ function extractSteps(workflow, jobName) {
1393
+ for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item.steps;
1394
+ throw new Error(`Static job '${jobName}' not found in workflow '${workflow.name}'`);
1395
+ }
1396
+ /**
1397
+ * Extract steps from a job generated by a DynamicJobFn.
1398
+ *
1399
+ * Re-evaluates the DynamicJobFn to get the generated Job[] array, then finds
1400
+ * the job by name and returns its steps. This is necessary because
1401
+ * DynamicJobFn-generated jobs' step functions are closures that can only be
1402
+ * obtained by calling the DynamicJobFn again.
1403
+ *
1404
+ * The function must be deterministic: given the same event context, it should
1405
+ * return the same jobs with the same step functions. When `expectedJobNames`
1406
+ * is provided, the re-evaluated output is compared against the original eval.
1407
+ * A sibling mismatch logs a warning; a missing target job throws a clear
1408
+ * determinism error.
1409
+ */
1410
+ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames) {
1411
+ const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
1412
+ const { $ } = await import("zx");
1413
+ const { createLogger } = await import("@kici-dev/shared");
1414
+ const { buildKiciApi } = await import("@kici-dev/sdk");
1415
+ const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
1416
+ const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
1417
+ const generatedJobs = await dynamicFn({
1418
+ $,
1419
+ ctx: {
1420
+ workflow: { name: workflow.name },
1421
+ event
1422
+ },
1423
+ log,
1424
+ env,
1425
+ kici
1426
+ });
1427
+ const actualNames = generatedJobs.map((j) => j.name);
1428
+ let droppedJobs = [];
1429
+ if (expectedJobNames) {
1430
+ const expectedSet = new Set(expectedJobNames);
1431
+ const actualSet = new Set(actualNames);
1432
+ const missing = expectedJobNames.filter((n) => !actualSet.has(n));
1433
+ const extra = actualNames.filter((n) => !expectedSet.has(n));
1434
+ droppedJobs = missing.filter((n) => n !== jobName);
1435
+ if (missing.length > 0 || extra.length > 0) {
1436
+ const detail = (missing.length > 0 ? `missing: [${missing.join(", ")}]` : "") + (missing.length > 0 && extra.length > 0 ? "; " : "") + (extra.length > 0 ? `unexpected: [${extra.join(", ")}]` : "");
1437
+ if (missing.includes(jobName)) throw new Error(`DynamicJobFn non-deterministic re-evaluation: job '${jobName}' no longer exists (workflow '${workflow.name}', index ${dynamicIndex}). Original eval produced: [${expectedJobNames.join(", ")}], re-eval produced: [${actualNames.join(", ")}]. DynamicJobFn must return the same jobs given the same event context. See docs/architecture/dynamic-jobs.md for guidance.`);
1438
+ log.warn(`DynamicJobFn non-deterministic re-evaluation detected (workflow '${workflow.name}', index ${dynamicIndex}): ${detail}. Target job '${jobName}' still exists — proceeding. DynamicJobFn should return the same jobs given the same event context.`);
1439
+ }
1440
+ }
1441
+ for (const genJob of generatedJobs) if (genJob.name === jobName) return {
1442
+ steps: genJob.steps,
1443
+ droppedJobs
1444
+ };
1445
+ throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
1446
+ }
1447
+ //#endregion
1448
+ //#region src/execution/download.ts
1449
+ /**
1450
+ * Shared HTTP/HTTPS download utility.
1451
+ *
1452
+ * Extracted from workflow-loader.ts to avoid duplication across
1453
+ * dep-restore.ts and workflow-loader.ts.
1454
+ */
1455
+ /** Download timeout: 5 minutes. */
1456
+ const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
1457
+ /**
1458
+ * Download content from an HTTP/HTTPS URL.
1459
+ *
1460
+ * Includes a 5-minute timeout to prevent the agent from hanging indefinitely
1461
+ * on slow or unresponsive endpoints.
1462
+ *
1463
+ * @param url - The URL to download from
1464
+ * @returns The response body as a Buffer
1465
+ */
1466
+ function downloadUrl(url) {
1467
+ return new Promise((resolve, reject) => {
1468
+ (url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
1469
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
1470
+ reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
1471
+ res.resume();
1472
+ return;
1473
+ }
1474
+ const chunks = [];
1475
+ res.on("data", (chunk) => chunks.push(chunk));
1476
+ res.on("end", () => resolve(Buffer.concat(chunks)));
1477
+ res.on("error", reject);
1478
+ }).on("error", reject);
1479
+ });
1480
+ }
1481
+ //#endregion
1482
+ //#region src/execution/source-restore.ts
1483
+ /**
1484
+ * `.kici/` source tarball restoration for execution agents.
1485
+ *
1486
+ * Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
1487
+ * and extracts it into `workDir/` so the workflow entry point becomes
1488
+ * importable. Mirrors the shape of `dep-restore.ts` but without the streaming
1489
+ * optimization — source tarballs are tiny (kilobytes, not the hundreds of
1490
+ * megabytes a `node_modules/` tarball carries).
1491
+ *
1492
+ * Note on integrity: `dispatch.sourceTarHash` is the workflow `contentHash`
1493
+ * (computed over the raw source per `workflow-loader.ts::computeContentHash`),
1494
+ * not the SHA-256 of the tarball bytes. The shared S3 cache key is derived
1495
+ * from that same contentHash, so a signed GET URL from the orchestrator
1496
+ * already establishes provenance for restored tarballs. Every
1497
+ * `loadWorkflowSource` call site — build, init, and dynamic eval — passes
1498
+ * the dispatched `contentHash` (and `resolvedHashFiles` when present) so
1499
+ * the lock-vs-source drift gate fires at each author-TS load site, not
1500
+ * only the build phase. That closes the corner cases where init or eval
1501
+ * runs without a preceding build (cache infrastructure unavailable, or a
1502
+ * build job that failed but left dynamic dispatch in flight).
1503
+ */
1504
+ const logger$1 = createLogger({ prefix: "source-restore" });
1505
+ async function extractSourceTarball(data, targetDir) {
1506
+ await mkdir(targetDir, { recursive: true });
1507
+ const readable = Readable.from(data);
1508
+ await new Promise((resolve, reject) => {
1509
+ readable.pipe(x({
1510
+ cwd: targetDir,
1511
+ gzip: true
1512
+ })).on("finish", resolve).on("error", reject);
1513
+ });
1514
+ }
1515
+ async function restoreSource(workDir, sourceTarUrl) {
1516
+ sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
1517
+ logger$1.info("Restoring .kici/ source from tarball", { sourceTarUrl });
1518
+ const startTime = Date.now();
1519
+ let data;
1520
+ if (sourceTarUrl.startsWith("file://")) {
1521
+ const localPath = fileURLToPath(sourceTarUrl);
1522
+ data = await fsPromises.readFile(localPath);
1523
+ } else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
1524
+ else throw new Error(`Unsupported source tarball URL scheme: ${sourceTarUrl}`);
1525
+ await extractSourceTarball(data, workDir);
1526
+ const durationMs = Date.now() - startTime;
1527
+ logger$1.info(".kici/ source restored", {
1528
+ sizeKB: (data.length / 1024).toFixed(2),
1529
+ durationMs
1530
+ });
1531
+ }
1532
+ //#endregion
1533
+ //#region src/execution/overlay-applier.ts
1534
+ /**
1535
+ * Agent-side overlay application.
1536
+ *
1537
+ * Downloads an encrypted tarball uploaded by the CLI, decrypts it using
1538
+ * X25519 ECDH shared secret, verifies file checksums from the manifest,
1539
+ * and applies the overlay (file additions/modifications + deletions)
1540
+ * on top of the cloned repository.
1541
+ *
1542
+ * Wire format: [12-byte IV][16-byte auth tag][ciphertext]
1543
+ * Same encryption scheme as packages/compiler/src/remote/encryption.ts.
1544
+ */
1545
+ const logger = createLogger({ prefix: "overlay-applier" });
1546
+ const IV_LENGTH = 12;
1547
+ const AUTH_TAG_LENGTH = 16;
1548
+ /**
1549
+ * Decrypt an encrypted buffer using AES-256-GCM.
1550
+ *
1551
+ * Wire format: [12-byte IV][16-byte auth tag][ciphertext]
1552
+ */
1553
+ function decryptBuffer(encrypted, aesKey) {
1554
+ if (encrypted.length < IV_LENGTH + AUTH_TAG_LENGTH) throw new Error(`Tarball decryption failed: encrypted data too short (${encrypted.length} bytes, minimum ${IV_LENGTH + AUTH_TAG_LENGTH} bytes)`);
1555
+ const iv = encrypted.subarray(0, IV_LENGTH);
1556
+ const authTag = encrypted.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
1557
+ const ciphertext = encrypted.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
1558
+ const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
1559
+ decipher.setAuthTag(authTag);
1560
+ try {
1561
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1562
+ } catch (err) {
1563
+ throw new Error(`Tarball decryption failed: ${toErrorMessage(err)}`);
1564
+ }
1565
+ }
1566
+ /**
1567
+ * Apply an overlay tarball to a cloned repository.
1568
+ *
1569
+ * Flow:
1570
+ * 1. Download encrypted tarball from tarballUrl
1571
+ * 2. Derive ECDH shared secret from orchestratorPrivateKey + cliPublicKey
1572
+ * 3. Decrypt tarball using AES-256-GCM
1573
+ * 4. Extract tar.gz to temp directory
1574
+ * 5. Read manifest.json and verify checksums
1575
+ * 6. Copy files to repoDir preserving directory structure
1576
+ * 7. Apply deletions from manifest
1577
+ * 8. Clean up temp files
1578
+ */
1579
+ async function applyOverlay(config) {
1580
+ const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
1581
+ const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "kici-overlay-"));
1582
+ try {
1583
+ logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
1584
+ let encryptedData;
1585
+ try {
1586
+ encryptedData = await downloadUrl(tarballUrl);
1587
+ } catch (err) {
1588
+ throw new Error(`Overlay download failed from ${tarballUrl.replace(/\?.*$/, "?[redacted]")}: ${toErrorMessage(err)}`);
1589
+ }
1590
+ const cliPubKeyBuf = Buffer.from(cliPublicKey, "base64");
1591
+ const aesKey = deriveSharedSecret(Buffer.from(orchestratorPrivateKey, "base64"), cliPubKeyBuf);
1592
+ const decryptedData = decryptBuffer(encryptedData, aesKey);
1593
+ logger.info("Extracting overlay tarball", { size: decryptedData.length });
1594
+ const extractDir = path.join(tmpDir, "extracted");
1595
+ await fsPromises.mkdir(extractDir, { recursive: true });
1596
+ try {
1597
+ const readable = Readable.from(decryptedData);
1598
+ await new Promise((resolve, reject) => {
1599
+ readable.pipe(x({
1600
+ cwd: extractDir,
1601
+ gzip: true
1602
+ })).on("finish", resolve).on("error", reject);
1603
+ });
1604
+ } catch (err) {
1605
+ throw new Error(`Overlay extraction failed: ${toErrorMessage(err)}`);
1606
+ }
1607
+ const manifestPath = path.join(extractDir, ".kici-overlay-tmp", "manifest.json");
1608
+ let manifestContent;
1609
+ try {
1610
+ manifestContent = await fsPromises.readFile(manifestPath, "utf-8");
1611
+ } catch {
1612
+ throw new Error("Overlay manifest not found: expected .kici-overlay-tmp/manifest.json in tarball");
1613
+ }
1614
+ const manifest = JSON.parse(manifestContent);
1615
+ const checksumFiles = Object.keys(manifest.checksums);
1616
+ const failedChecksums = [];
1617
+ for (const file of checksumFiles) {
1618
+ const extractedPath = path.join(extractDir, file);
1619
+ try {
1620
+ const actualHash = await sha256File(extractedPath);
1621
+ if (actualHash !== manifest.checksums[file]) failedChecksums.push(`${file}: expected ${manifest.checksums[file]}, got ${actualHash}`);
1622
+ } catch {
1623
+ failedChecksums.push(`${file}: file not found in tarball`);
1624
+ }
1625
+ }
1626
+ if (failedChecksums.length > 0) throw new Error(`Overlay checksum verification failed for ${failedChecksums.length} file(s):\n` + failedChecksums.map((f) => ` - ${f}`).join("\n"));
1627
+ let filesApplied = 0;
1628
+ for (const file of checksumFiles) {
1629
+ const srcPath = path.join(extractDir, file);
1630
+ const destPath = path.join(repoDir, file);
1631
+ await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
1632
+ await fsPromises.copyFile(srcPath, destPath);
1633
+ filesApplied++;
1634
+ }
1635
+ let filesDeleted = 0;
1636
+ for (const file of manifest.deletions) {
1637
+ const targetPath = path.join(repoDir, file);
1638
+ try {
1639
+ await fsPromises.unlink(targetPath);
1640
+ filesDeleted++;
1641
+ } catch {
1642
+ logger.debug("Deletion target not found, skipping", { file });
1643
+ }
1644
+ }
1645
+ logger.info("Overlay applied successfully", {
1646
+ filesApplied,
1647
+ filesDeleted
1648
+ });
1649
+ return {
1650
+ filesApplied,
1651
+ filesDeleted,
1652
+ verified: true
1653
+ };
1654
+ } finally {
1655
+ await fsPromises.rm(tmpDir, {
1656
+ recursive: true,
1657
+ force: true
1658
+ }).catch(() => {});
1659
+ }
1660
+ }
1661
+ //#endregion
1662
+ //#region src/execution/sandbox/workflow-runner.ts
1663
+ /**
1664
+ * Workflow Runner -- standalone entry point that runs INSIDE the sandbox.
1665
+ *
1666
+ * This is the code that actually executes customer workflows in isolation.
1667
+ * The agent process never loads or executes customer code -- only this runner does.
1668
+ *
1669
+ * Supports two IPC modes:
1670
+ * - Fork IPC: Used by bare-metal (bwrap) and Firecracker backends. Messages go
1671
+ * via Node.js IPC channel (process.send / process.on('message')).
1672
+ * - Stdio IPC: Used by container backend (docker exec). JSON-line messages flow
1673
+ * bidirectionally: agent writes to container stdin, runner writes to stdout.
1674
+ *
1675
+ * This file is compiled alongside the agent by rolldown (existing build), but
1676
+ * runs as a SEPARATE process spawned by the sandbox backend.
1677
+ */
1678
+ process.on("uncaughtException", (err) => {
1679
+ process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
1680
+ if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
1681
+ process.exit(99);
1682
+ });
1683
+ process.on("unhandledRejection", (reason) => {
1684
+ const msg = toErrorMessage(reason);
1685
+ const stack = reason instanceof Error ? reason.stack : void 0;
1686
+ process.stderr.write(`[workflow-runner] UNHANDLED REJECTION: ${msg}\n`);
1687
+ if (stack) process.stderr.write(`[workflow-runner] Stack: ${stack}\n`);
1688
+ process.exit(98);
1689
+ });
1690
+ /** Whether we are running in fork IPC mode (Node IPC channel available). */
1691
+ const isForkMode = typeof process.send === "function";
1692
+ /**
1693
+ * Saved references to the original stdout/stderr write functions.
1694
+ * Used by sendMessage (container mode IPC) and output capture to avoid
1695
+ * infinite recursion when process.stdout.write is monkey-patched.
1696
+ */
1697
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
1698
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
1699
+ /**
1700
+ * Current step index for console.log/console.error capture.
1701
+ * When >= 0, process.stdout/stderr writes are intercepted and sent as
1702
+ * log.line IPC messages for the given step. Set to -1 outside step execution.
1703
+ */
1704
+ let captureStepIndex = -1;
1705
+ /**
1706
+ * Workflow-level capture flag for the pre-step `prepare` phase
1707
+ * (module load, concurrency-group evaluation, rule evaluation).
1708
+ *
1709
+ * When true, process.stdout/stderr writes are captured and emitted as
1710
+ * log.line IPC messages with `stepIndex: -1` — the same bucket already used
1711
+ * by existing meta-messages like `[kici] Concurrency group: ...`. This means
1712
+ * user console.log inside module top-level, concurrency-group functions, and
1713
+ * rule check functions lands in the job's workflow-level log file
1714
+ * (`executions/{runId}/job-{name}/step--1.log`) alongside runner narration.
1715
+ *
1716
+ * Mutually exclusive with `captureStepIndex >= 0` — the step loop resets
1717
+ * this flag to false before setting `captureStepIndex` to a real step index.
1718
+ */
1719
+ let capturePrepareActive = false;
1720
+ /** The maskedSend function used by the output capture. Set during main(). */
1721
+ let captureSendFn = null;
1722
+ function captureIsActive() {
1723
+ return (captureStepIndex >= 0 || capturePrepareActive) && captureSendFn !== null;
1724
+ }
1725
+ function captureTargetIndex() {
1726
+ return captureStepIndex >= 0 ? captureStepIndex : -1;
1727
+ }
1728
+ /**
1729
+ * Install monkey-patches on process.stdout.write and process.stderr.write
1730
+ * so that console.log() / console.error() calls from customer step code
1731
+ * are captured and forwarded as log.line IPC messages.
1732
+ *
1733
+ * Without this, only subprocess output (via ctx.$) goes through zx's log
1734
+ * callback. Direct console.log() calls from pure JS/TS step code would be
1735
+ * lost (stdout goes to a pipe the fork-runner drains for debugging, but
1736
+ * never enters the log streaming pipeline).
1737
+ */
1738
+ function installOutputCapture() {
1739
+ let stdoutBuf = "";
1740
+ let stderrBuf = "";
1741
+ process.stdout.write = ((chunk, encodingOrCb, cb) => {
1742
+ if (captureIsActive() && captureSendFn) {
1743
+ const text = typeof chunk === "string" ? chunk : chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf8");
1744
+ stdoutBuf += text;
1745
+ const lines = stdoutBuf.split("\n");
1746
+ stdoutBuf = lines.pop();
1747
+ const stepIdx = captureTargetIndex();
1748
+ for (const line of lines) if (line) captureSendFn({
1749
+ type: "log.line",
1750
+ stepIndex: stepIdx,
1751
+ line
1752
+ });
1753
+ }
1754
+ if (isForkMode) return origStdoutWrite(chunk, encodingOrCb, cb);
1755
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
1756
+ if (callback) callback();
1757
+ return true;
1758
+ });
1759
+ process.stderr.write = ((chunk, encodingOrCb, cb) => {
1760
+ if (captureIsActive() && captureSendFn) {
1761
+ const text = typeof chunk === "string" ? chunk : chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf8");
1762
+ stderrBuf += text;
1763
+ const lines = stderrBuf.split("\n");
1764
+ stderrBuf = lines.pop();
1765
+ const stepIdx = captureTargetIndex();
1766
+ for (const line of lines) if (line) captureSendFn({
1767
+ type: "log.line",
1768
+ stepIndex: stepIdx,
1769
+ line
1770
+ });
1771
+ }
1772
+ return origStderrWrite(chunk, encodingOrCb, cb);
1773
+ });
1774
+ }
1775
+ /**
1776
+ * Flush any remaining partial lines in the output capture buffers.
1777
+ * Called after each step completes to ensure no trailing output is lost.
1778
+ */
1779
+ function flushOutputCapture() {
1780
+ if (captureIsActive()) {
1781
+ process.stdout.write("\n");
1782
+ process.stderr.write("\n");
1783
+ }
1784
+ }
1785
+ /**
1786
+ * Send a message from the runner to the agent.
1787
+ *
1788
+ * In fork mode: uses Node.js IPC channel (process.send).
1789
+ * In stdio mode: writes a JSON line to stdout via origStdoutWrite
1790
+ * (bypasses any monkey-patch on process.stdout.write).
1791
+ */
1792
+ function sendMessage(msg) {
1793
+ if (isForkMode) process.send(msg);
1794
+ else origStdoutWrite(JSON.stringify(msg) + "\n");
1795
+ }
1796
+ /**
1797
+ * Readline interface for stdin in stdio (container) mode.
1798
+ * Created once and reused for both receiveRequest() and the response listener.
1799
+ */
1800
+ let stdinRl = null;
1801
+ /**
1802
+ * Get or create the stdin readline interface for container/stdio mode.
1803
+ */
1804
+ function getStdinRl() {
1805
+ if (!stdinRl) {
1806
+ process.stdin.setEncoding("utf-8");
1807
+ stdinRl = createInterface({
1808
+ input: process.stdin,
1809
+ crlfDelay: Infinity
1810
+ });
1811
+ }
1812
+ return stdinRl;
1813
+ }
1814
+ /**
1815
+ * Receive the execution request from the agent.
1816
+ *
1817
+ * In fork mode: listens for the first 'execute' message on the IPC channel.
1818
+ * In stdio mode: reads JSON-lines from stdin, resolves on the first 'execute' message.
1819
+ *
1820
+ * @returns The execute message containing the JobExecutionRequest.
1821
+ */
1822
+ function receiveRequest() {
1823
+ if (isForkMode) return new Promise((resolve) => {
1824
+ const handler = (msg) => {
1825
+ if (msg.type === "execute") {
1826
+ process.removeListener("message", handler);
1827
+ resolve(msg);
1828
+ }
1829
+ };
1830
+ process.on("message", handler);
1831
+ });
1832
+ else return new Promise((resolve, reject) => {
1833
+ const rl = getStdinRl();
1834
+ const handler = (line) => {
1835
+ try {
1836
+ const parsed = JSON.parse(line);
1837
+ if (parsed.type === "execute") {
1838
+ rl.removeListener("line", handler);
1839
+ resolve(parsed);
1840
+ }
1841
+ } catch (e) {
1842
+ rl.removeListener("line", handler);
1843
+ reject(/* @__PURE__ */ new Error(`Failed to parse stdin JSON: ${toErrorMessage(e)}`));
1844
+ }
1845
+ };
1846
+ rl.on("line", handler);
1847
+ });
1848
+ }
1849
+ initZx();
1850
+ $.verbose = false;
1851
+ $.quiet = false;
1852
+ /** Global abort flag. Set when abort message or SIGTERM is received. */
1853
+ let aborted = false;
1854
+ /**
1855
+ * Force cancel flag. When true, skip all remaining hooks and exit immediately.
1856
+ * Set when abort IPC arrives with force=true, or on second cancel while already cancelling.
1857
+ */
1858
+ let forceAborted = false;
1859
+ /**
1860
+ * Pending promises for event.emit requests awaiting responses from the agent.
1861
+ *
1862
+ * Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
1863
+ * Value: resolve/reject pair for the waiting ctx.emit() call
1864
+ */
1865
+ const pendingEmitResponses = /* @__PURE__ */ new Map();
1866
+ /** Default timeout for event.emit responses (5 seconds per research doc). */
1867
+ const EMIT_RESPONSE_TIMEOUT_MS = 5e3;
1868
+ /**
1869
+ * Wait for an event.emit.response from the agent with the given requestId.
1870
+ *
1871
+ * On timeout, resolves with a synthetic success receipt and logs a warning
1872
+ * (per research doc pitfall 5: event was already persisted by orchestrator,
1873
+ * so it will still be routed even if the ack is lost).
1874
+ */
1875
+ function waitForEmitResponse(requestId) {
1876
+ return new Promise((resolve) => {
1877
+ const timer = setTimeout(() => {
1878
+ pendingEmitResponses.delete(requestId);
1879
+ process.stderr.write(`[workflow-runner] event.emit response timeout for requestId=${requestId} -- returning synthetic receipt\n`);
1880
+ resolve({
1881
+ type: "event.emit.response",
1882
+ requestId,
1883
+ deliveryId: `timeout-${requestId}`
1884
+ });
1885
+ }, EMIT_RESPONSE_TIMEOUT_MS);
1886
+ pendingEmitResponses.set(requestId, {
1887
+ resolve: (response) => {
1888
+ clearTimeout(timer);
1889
+ pendingEmitResponses.delete(requestId);
1890
+ resolve(response);
1891
+ },
1892
+ reject: (err) => {
1893
+ clearTimeout(timer);
1894
+ pendingEmitResponses.delete(requestId);
1895
+ resolve({
1896
+ type: "event.emit.response",
1897
+ requestId,
1898
+ error: err.message
1899
+ });
1900
+ },
1901
+ timer
1902
+ });
1903
+ });
1904
+ }
1905
+ /**
1906
+ * Pending promise for the concurrency.ack response from the agent.
1907
+ * Only one outstanding concurrency report per job (one workflow = one group).
1908
+ */
1909
+ let pendingConcurrencyAck = null;
1910
+ /**
1911
+ * Wait for a concurrency.ack from the agent (relayed from orchestrator).
1912
+ * The timeout is configurable (default 30s) and fails the job on expiry.
1913
+ */
1914
+ function waitForConcurrencyAck(timeoutMs) {
1915
+ return new Promise((resolve, reject) => {
1916
+ const timer = setTimeout(() => {
1917
+ pendingConcurrencyAck = null;
1918
+ reject(/* @__PURE__ */ new Error(`Concurrency group ack timed out after ${timeoutMs}ms`));
1919
+ }, timeoutMs);
1920
+ pendingConcurrencyAck = {
1921
+ resolve: (ack) => {
1922
+ clearTimeout(timer);
1923
+ pendingConcurrencyAck = null;
1924
+ resolve(ack);
1925
+ },
1926
+ reject: (err) => {
1927
+ clearTimeout(timer);
1928
+ pendingConcurrencyAck = null;
1929
+ reject(err);
1930
+ },
1931
+ timer
1932
+ };
1933
+ });
1934
+ }
1935
+ /**
1936
+ * Pending promises for agent.api.response messages from the agent.
1937
+ * Key: requestId, Value: resolve/reject pair.
1938
+ */
1939
+ const pendingApiResponses = /* @__PURE__ */ new Map();
1940
+ const API_RESPONSE_TIMEOUT_MS = 15e3;
1941
+ /**
1942
+ * Wait for an agent.api.response from the agent with the given requestId.
1943
+ */
1944
+ function waitForApiResponse(requestId) {
1945
+ return new Promise((resolve, reject) => {
1946
+ const timer = setTimeout(() => {
1947
+ pendingApiResponses.delete(requestId);
1948
+ reject(/* @__PURE__ */ new Error(`Agent API request timed out after ${API_RESPONSE_TIMEOUT_MS}ms`));
1949
+ }, API_RESPONSE_TIMEOUT_MS);
1950
+ pendingApiResponses.set(requestId, {
1951
+ resolve: (result) => {
1952
+ clearTimeout(timer);
1953
+ pendingApiResponses.delete(requestId);
1954
+ resolve(result);
1955
+ },
1956
+ reject: (err) => {
1957
+ clearTimeout(timer);
1958
+ pendingApiResponses.delete(requestId);
1959
+ reject(err);
1960
+ },
1961
+ timer
1962
+ });
1963
+ });
1964
+ }
1965
+ /**
1966
+ * Dispatch an agent-to-runner message to the appropriate pending handler.
1967
+ * Shared between fork mode (IPC channel) and stdio mode (stdin readline).
1968
+ */
1969
+ function dispatchAgentMessage(msg) {
1970
+ if (msg.type === "abort") {
1971
+ aborted = true;
1972
+ if (msg.force) forceAborted = true;
1973
+ } else if (msg.type === "event.emit.response") {
1974
+ const pending = pendingEmitResponses.get(msg.requestId);
1975
+ if (pending) pending.resolve(msg);
1976
+ } else if (msg.type === "concurrency.ack") {
1977
+ if (pendingConcurrencyAck) pendingConcurrencyAck.resolve(msg);
1978
+ } else if (msg.type === "agent.api.response") {
1979
+ const pending = pendingApiResponses.get(msg.requestId);
1980
+ if (pending) if (msg.error) pending.reject(new Error(msg.error));
1981
+ else pending.resolve(msg.result);
1982
+ }
1983
+ }
1984
+ if (isForkMode) process.on("message", (msg) => {
1985
+ dispatchAgentMessage(msg);
1986
+ });
1987
+ else getStdinRl().on("line", (line) => {
1988
+ try {
1989
+ const msg = JSON.parse(line);
1990
+ if (msg.type !== "execute") dispatchAgentMessage(msg);
1991
+ } catch {}
1992
+ });
1993
+ process.on("SIGTERM", () => {
1994
+ aborted = true;
1995
+ });
1996
+ /**
1997
+ * Create a Logger that sends log lines via IPC.
1998
+ *
1999
+ * Each log call formats the line and sends it as a log.line IPC message.
2000
+ * Format matches the existing createStepLogger: [stepName] [level?] message args...
2001
+ */
2002
+ function createIpcLogger(stepIndex, stepName, sendFn) {
2003
+ const formatLine = (level, message, args) => {
2004
+ const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message;
2005
+ return `[${stepName}] ${level !== "info" ? `[${level}] ` : ""}${formatted}`;
2006
+ };
2007
+ return {
2008
+ info: (message, ...args) => {
2009
+ sendFn({
2010
+ type: "log.line",
2011
+ stepIndex,
2012
+ line: formatLine("info", message, args)
2013
+ });
2014
+ },
2015
+ warn: (message, ...args) => {
2016
+ sendFn({
2017
+ type: "log.line",
2018
+ stepIndex,
2019
+ line: formatLine("warn", message, args)
2020
+ });
2021
+ },
2022
+ error: (message, ...args) => {
2023
+ sendFn({
2024
+ type: "log.line",
2025
+ stepIndex,
2026
+ line: formatLine("error", message, args)
2027
+ });
2028
+ },
2029
+ debug: (message, ...args) => {
2030
+ if (process.env.KICI_LOG_LEVEL === "debug") sendFn({
2031
+ type: "log.line",
2032
+ stepIndex,
2033
+ line: formatLine("debug", message, args)
2034
+ });
2035
+ }
2036
+ };
2037
+ }
2038
+ /**
2039
+ * Build StepSecrets from the job execution request, wired with the per-step
2040
+ * file-mount host (used by `ctx.secrets.mountFile` / `exposeFile`).
2041
+ *
2042
+ * Returns a `{ secrets, dispose }` handle: `secrets` is bound into the step
2043
+ * context, and `dispose` is invoked by the step-loop's `finally` to remove
2044
+ * the per-step tmpdir and clear any env vars set via `exposeFile`.
2045
+ *
2046
+ * The mounted-file content is registered with the active `LogMasker` so any
2047
+ * subprocess that echoes the credential gets `***`-replaced in the streamed
2048
+ * log -- same masker the rest of the runner uses, so masking is global, not
2049
+ * per-step.
2050
+ */
2051
+ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
2052
+ const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
2053
+ let stepTmpdir = null;
2054
+ const exposedEnvVars = /* @__PURE__ */ new Set();
2055
+ let mountCounter = 0;
2056
+ const env = process.env;
2057
+ async function ensureTmpdir() {
2058
+ if (stepTmpdir === null) stepTmpdir = await fsPromises.mkdtemp(join(tmpdir(), "kici-secret-files-"));
2059
+ return stepTmpdir;
2060
+ }
2061
+ return createStepSecrets(mergedFlat, env, request.secretMeta, {
2062
+ host: {
2063
+ async writeMountedFile(args) {
2064
+ const dir = await ensureTmpdir();
2065
+ mountCounter += 1;
2066
+ const filePath = join(dir, args.name ?? `secret-${mountCounter}`);
2067
+ await fsPromises.writeFile(filePath, args.content);
2068
+ await fsPromises.chmod(filePath, args.mode);
2069
+ masker.registerSecrets({ [`__mount_${mountCounter}__`]: args.content });
2070
+ onMaskerSecretsAdded();
2071
+ return filePath;
2072
+ },
2073
+ trackExposedEnv(envVar) {
2074
+ exposedEnvVars.add(envVar);
2075
+ }
2076
+ },
2077
+ cleanup: async () => {
2078
+ for (const envVar of exposedEnvVars) {
2079
+ delete env[envVar];
2080
+ delete process.env[envVar];
2081
+ }
2082
+ exposedEnvVars.clear();
2083
+ if (stepTmpdir !== null) {
2084
+ await fsPromises.rm(stepTmpdir, {
2085
+ recursive: true,
2086
+ force: true
2087
+ });
2088
+ stepTmpdir = null;
2089
+ }
2090
+ },
2091
+ onDisposeError: (err) => {
2092
+ origStderrWrite(`[workflow-runner] secret-file cleanup error: ${err instanceof Error ? err.message : String(err)}\n`);
2093
+ }
2094
+ });
2095
+ }
2096
+ /**
2097
+ * Create a LogMasker initialized with all secret values from the request.
2098
+ *
2099
+ * Collects values from both flat secrets and all namespaced context secrets,
2100
+ * deduplicating before registration.
2101
+ */
2102
+ function createSecretMasker(request) {
2103
+ const masker = new LogMasker();
2104
+ const allSecrets = {};
2105
+ if (request.secrets) Object.assign(allSecrets, request.secrets);
2106
+ if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
2107
+ masker.registerSecrets(allSecrets);
2108
+ return masker;
2109
+ }
2110
+ /**
2111
+ * Create a StepContext natively inside the workflow runner.
2112
+ *
2113
+ * The context is reconstructed from the environment and IPC request fields --
2114
+ * NOT serialized across the process boundary. This means zx $ runs natively
2115
+ * inside this process with full shell access.
2116
+ */
2117
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
2118
+ let zxLineBuf = "";
2119
+ const step$ = $({
2120
+ cwd: workDir,
2121
+ env: { ...process.env },
2122
+ verbose: false,
2123
+ quiet: false,
2124
+ log: ((entry) => {
2125
+ if (entry.kind === "stdout" || entry.kind === "stderr") {
2126
+ const text = typeof entry.data === "string" ? entry.data : String(entry.data ?? "");
2127
+ zxLineBuf += text;
2128
+ const lines = zxLineBuf.split("\n");
2129
+ zxLineBuf = lines.pop();
2130
+ for (const line of lines) if (line) maskedSendFn({
2131
+ type: "log.line",
2132
+ stepIndex,
2133
+ line
2134
+ });
2135
+ return;
2136
+ }
2137
+ })
2138
+ });
2139
+ const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
2140
+ return {
2141
+ $: step$,
2142
+ log,
2143
+ env: process.env,
2144
+ setEnv: (key, value) => {
2145
+ if (operatorSecretKeys.has(key)) {
2146
+ log.warn(`Cannot override operator secret "${key}" via setEnv — value preserved`);
2147
+ return;
2148
+ }
2149
+ process.env[key] = value;
2150
+ },
2151
+ addPath: (dir) => {
2152
+ process.env.PATH = dir + ":" + (process.env.PATH ?? "");
2153
+ },
2154
+ inputs: {},
2155
+ secrets,
2156
+ workflow: { name: request.workflowName },
2157
+ job: {
2158
+ name: request.jobName,
2159
+ runsOn: request.runsOn
2160
+ },
2161
+ isTestRun: request.isTestRun ?? false,
2162
+ environment: request.environment,
2163
+ emit: async (eventName, payload, options) => {
2164
+ const reqId = randomUUID();
2165
+ sendMessage({
2166
+ type: "event.emit",
2167
+ requestId: reqId,
2168
+ eventName,
2169
+ payload: payload ?? {},
2170
+ ...options?.target && { target: options.target }
2171
+ });
2172
+ const response = await waitForEmitResponse(reqId);
2173
+ if (response.error) throw new Error(`Event emission failed: ${response.error}`);
2174
+ return { deliveryId: response.deliveryId };
2175
+ },
2176
+ outputsOf: (ref) => {
2177
+ return resolveStepOutputs(ref, outputsMap, refMap);
2178
+ },
2179
+ jobOutputs: (ref) => {
2180
+ return resolveJobOutputs(ref, jobOutputsMap);
2181
+ },
2182
+ setSecretOutput: (key, value) => {
2183
+ secretOutputs.set(key, value);
2184
+ },
2185
+ kici: buildKiciApi(async (method, params) => {
2186
+ const reqId = randomUUID();
2187
+ sendMessage({
2188
+ type: "agent.api.request",
2189
+ requestId: reqId,
2190
+ method,
2191
+ params: params ?? {}
2192
+ });
2193
+ return waitForApiResponse(reqId);
2194
+ }),
2195
+ ...request.event && { rawPayload: request.event },
2196
+ ...request.provider && { provider: request.provider }
2197
+ };
2198
+ }
2199
+ /**
2200
+ * Check if a file exists at the given path.
2201
+ */
2202
+ function fileExists(p) {
2203
+ return existsSync(p);
2204
+ }
2205
+ function trace(msg) {
2206
+ origStderrWrite(`[workflow-runner:trace] ${msg}\n`);
2207
+ }
2208
+ /**
2209
+ * Send a job.complete + process.exit. Centralises the abort-mid-prepare path
2210
+ * (after clone / after deps / after rules) so the messages and exit code
2211
+ * stay consistent.
2212
+ */
2213
+ function abortAndExit(reason) {
2214
+ trace(reason);
2215
+ flushOutputCapture();
2216
+ capturePrepareActive = false;
2217
+ sendMessage({
2218
+ type: "job.complete",
2219
+ status: ExecutionJobStatus.enum.failed,
2220
+ stepResults: []
2221
+ });
2222
+ process.exit(1);
2223
+ }
2224
+ /**
2225
+ * Phase 1 — Clone the source / workflow repos (or skip when checkout=false).
2226
+ * Three modes: full-repo overlay-only (no clone), global dual-clone
2227
+ * (workflow + source), or single-repo clone. Each mode emits the same
2228
+ * progress IPC log lines as before.
2229
+ */
2230
+ async function cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal) {
2231
+ if (request.checkout === false) return;
2232
+ if (request.fullRepo) {
2233
+ trace("fullRepo mode -- skipping git clone, workspace from overlay");
2234
+ await fsPromises.mkdir(workDir, { recursive: true });
2235
+ sendMessage({
2236
+ type: "log.line",
2237
+ stepIndex: -1,
2238
+ line: "[workflow-runner] Full-repo mode: skipping git clone (workspace from overlay tarball)"
2239
+ });
2240
+ return;
2241
+ }
2242
+ if (isGlobal) {
2243
+ trace(`starting dual-clone (global workflow)`);
2244
+ await fsPromises.mkdir(workflowDir, { recursive: true });
2245
+ await fsPromises.mkdir(sourceDir, { recursive: true });
2246
+ const workflowAuth = request.workflowAuth ?? request.sourceAuth;
2247
+ const sourceAuth = request.sourceAuth ?? request.workflowAuth;
2248
+ sendMessage({
2249
+ type: "log.line",
2250
+ stepIndex: -1,
2251
+ line: `[workflow-runner] Global workflow: cloning workflow repo ${request.workflowRepoUrl} ref=${request.workflowRef} into ${workflowDir}`
2252
+ });
2253
+ await gitClone({
2254
+ repoUrl: request.workflowRepoUrl,
2255
+ ref: request.workflowRef ?? "",
2256
+ sha: request.workflowSha ?? "",
2257
+ workDir: workflowDir,
2258
+ gitAuth: workflowAuth,
2259
+ token: workflowAuth ? void 0 : request.token
2260
+ });
2261
+ trace("workflow repo clone complete");
2262
+ await excludeScratchFromGit(workflowDir);
2263
+ sendMessage({
2264
+ type: "log.line",
2265
+ stepIndex: -1,
2266
+ line: `[workflow-runner] Global workflow: cloning source repo ${request.repoUrl} ref=${request.ref} into ${sourceDir}`
2267
+ });
2268
+ await gitClone({
2269
+ repoUrl: request.repoUrl,
2270
+ ref: request.ref,
2271
+ sha: request.sha,
2272
+ workDir: sourceDir,
2273
+ gitAuth: sourceAuth,
2274
+ token: sourceAuth ? void 0 : request.token
2275
+ });
2276
+ trace("source repo clone complete");
2277
+ sendMessage({
2278
+ type: "log.line",
2279
+ stepIndex: -1,
2280
+ line: "[workflow-runner] Dual-clone complete"
2281
+ });
2282
+ return;
2283
+ }
2284
+ trace("starting git clone");
2285
+ sendMessage({
2286
+ type: "log.line",
2287
+ stepIndex: -1,
2288
+ line: `[workflow-runner] Cloning ${request.repoUrl} ref=${request.ref} into ${workDir}`
2289
+ });
2290
+ await gitClone({
2291
+ repoUrl: request.repoUrl,
2292
+ ref: request.ref,
2293
+ sha: request.sha,
2294
+ workDir,
2295
+ gitAuth: request.sourceAuth,
2296
+ token: request.sourceAuth ? void 0 : request.token
2297
+ });
2298
+ await excludeScratchFromGit(workDir);
2299
+ sendMessage({
2300
+ type: "log.line",
2301
+ stepIndex: -1,
2302
+ line: "[workflow-runner] Clone complete"
2303
+ });
2304
+ trace("git clone complete");
2305
+ }
2306
+ /**
2307
+ * Phase 1b — Apply the encrypted overlay tarball when present (test runs
2308
+ * with uncommitted changes). For global workflows the overlay applies to the
2309
+ * workflow repo (the one carrying `.kici/`).
2310
+ */
2311
+ async function applyOverlayIfRequested(request, workflowDir) {
2312
+ if (!request.tarballUrl || !request.cliPublicKey || !request.orchestratorPrivateKey) return;
2313
+ trace("applying overlay tarball");
2314
+ sendMessage({
2315
+ type: "log.line",
2316
+ stepIndex: -1,
2317
+ line: "[workflow-runner] Applying overlay (uncommitted changes)"
2318
+ });
2319
+ const overlayResult = await applyOverlay({
2320
+ tarballUrl: request.tarballUrl,
2321
+ cliPublicKey: request.cliPublicKey,
2322
+ orchestratorPrivateKey: request.orchestratorPrivateKey,
2323
+ repoDir: workflowDir
2324
+ });
2325
+ sendMessage({
2326
+ type: "log.line",
2327
+ stepIndex: -1,
2328
+ line: `[workflow-runner] Overlay applied: ${overlayResult.filesApplied} files changed, ${overlayResult.filesDeleted} files deleted`
2329
+ });
2330
+ trace(`overlay applied: ${overlayResult.filesApplied} files, ${overlayResult.filesDeleted} deletions`);
2331
+ }
2332
+ /**
2333
+ * Phase 2 — Restore deps from cache (with hash-mismatch hard-fail) OR fall
2334
+ * back to inline install. Skipped when `.kici/package.json` doesn't exist.
2335
+ * For global workflows deps come from the workflow repo (where `.kici/` lives).
2336
+ */
2337
+ async function installDependenciesIfNeeded(workflowDir, request) {
2338
+ const kiciDir = join(workflowDir, ".kici");
2339
+ const hasPackageJson = fileExists(join(kiciDir, "package.json"));
2340
+ trace(`deps: kiciDir=${kiciDir}, hasPackageJson=${hasPackageJson}, depsUrl=${request.depsUrl ?? "none"}`);
2341
+ if (!hasPackageJson) return;
2342
+ if (request.depsUrl) {
2343
+ trace("restoring deps from cache");
2344
+ sendMessage({
2345
+ type: "log.line",
2346
+ stepIndex: -1,
2347
+ line: `[workflow-runner] Restoring deps from ${request.depsUrl}`
2348
+ });
2349
+ try {
2350
+ await restoreDeps(workflowDir, request.depsUrl, request.depsHash);
2351
+ sendMessage({
2352
+ type: "log.line",
2353
+ stepIndex: -1,
2354
+ line: "[workflow-runner] Deps restored from cache"
2355
+ });
2356
+ trace("deps restored from cache");
2357
+ } catch (err) {
2358
+ if (err instanceof Error && err.message.includes("hash mismatch")) throw err;
2359
+ trace(`cache restore failed: ${toErrorMessage(err)}, falling back`);
2360
+ sendMessage({
2361
+ type: "log.line",
2362
+ stepIndex: -1,
2363
+ line: `[workflow-runner] Cache restore failed (${toErrorMessage(err)}), falling back to inline install`
2364
+ });
2365
+ await installDeps(kiciDir, {
2366
+ npmRegistries: request.npmRegistries,
2367
+ installEnvSecrets: request.installEnvSecrets,
2368
+ jobIdShort: request.jobIdShort
2369
+ });
2370
+ trace("fallback install complete");
2371
+ }
2372
+ return;
2373
+ }
2374
+ if (fileExists(join(kiciDir, "node_modules"))) {
2375
+ trace("node_modules already exists, skipping install");
2376
+ sendMessage({
2377
+ type: "log.line",
2378
+ stepIndex: -1,
2379
+ line: "[workflow-runner] Deps already present (node_modules exists), skipping install"
2380
+ });
2381
+ return;
2382
+ }
2383
+ trace("installing deps inline (no cache)");
2384
+ sendMessage({
2385
+ type: "log.line",
2386
+ stepIndex: -1,
2387
+ line: "[workflow-runner] Installing deps inline (no cache)"
2388
+ });
2389
+ try {
2390
+ await installDeps(kiciDir, {
2391
+ npmRegistries: request.npmRegistries,
2392
+ installEnvSecrets: request.installEnvSecrets,
2393
+ jobIdShort: request.jobIdShort
2394
+ });
2395
+ trace("installDeps() returned successfully");
2396
+ } catch (depErr) {
2397
+ const depMsg = toErrorMessage(depErr);
2398
+ const depStack = depErr instanceof Error ? depErr.stack : void 0;
2399
+ trace(`installDeps THREW: ${depMsg}`);
2400
+ if (depStack) trace(`installDeps stack: ${depStack}`);
2401
+ throw depErr;
2402
+ }
2403
+ sendMessage({
2404
+ type: "log.line",
2405
+ stepIndex: -1,
2406
+ line: "[workflow-runner] Deps installed"
2407
+ });
2408
+ trace("deps installed IPC sent");
2409
+ }
2410
+ /**
2411
+ * Phase 2b — Restore the cached `.kici/` source tarball over the cloned
2412
+ * workflow root so the loaded workflow's contentHash matches the lock file
2413
+ * exactly. No-op when no `sourceTarUrl` was provided.
2414
+ */
2415
+ async function restoreSourceTarballIfRequested(workflowRoot, request) {
2416
+ if (!request.sourceTarUrl) return;
2417
+ trace(`restoring .kici/ source from tarball url=${request.sourceTarUrl}`);
2418
+ sendMessage({
2419
+ type: "log.line",
2420
+ stepIndex: -1,
2421
+ line: "[workflow-runner] Restoring .kici/ source from cached tarball"
2422
+ });
2423
+ await restoreSource(workflowRoot, request.sourceTarUrl);
2424
+ trace("source tarball restored");
2425
+ }
2426
+ /**
2427
+ * Phase 3 — Install the stdout/stderr capture, then load the workflow module
2428
+ * via the oxc-transform ESM loader. Capture is enabled for the prepare phase
2429
+ * (module load → concurrency → rules) so user `console.log` lands in the
2430
+ * workflow-level log bucket (stepIndex: -1). On load failure we flush + clear
2431
+ * the prepare flag before rethrowing so the catch handler in main() doesn't
2432
+ * see stale buffer state.
2433
+ */
2434
+ async function loadWorkflowModuleWithCapture(workflowRoot, request, isGlobal, maskedSend) {
2435
+ let sourceFile = request.sourceFile ?? ".kici/workflows/ci.ts";
2436
+ if (sourceFile && !sourceFile.startsWith(".kici/")) sourceFile = `.kici/${sourceFile}`;
2437
+ trace(`loading workflow module, sourceFile=${sourceFile}, workflowRoot=${workflowRoot}, cachedSource=${!!request.sourceTarUrl}`);
2438
+ sendMessage({
2439
+ type: "log.line",
2440
+ stepIndex: -1,
2441
+ line: `[workflow-runner] Loading workflow module${isGlobal ? " (global=true, from workflow repo)" : ""}`
2442
+ });
2443
+ captureSendFn = maskedSend;
2444
+ installOutputCapture();
2445
+ capturePrepareActive = true;
2446
+ try {
2447
+ const loaded = await loadWorkflowSource(workflowRoot, sourceFile, request.contentHash, request.resolvedHashFiles);
2448
+ sendMessage({
2449
+ type: "log.line",
2450
+ stepIndex: -1,
2451
+ line: "[workflow-runner] Workflow module loaded"
2452
+ });
2453
+ trace(`workflow module loaded, exports=${Object.keys(loaded.module).join(",")}`);
2454
+ return loaded;
2455
+ } catch (err) {
2456
+ flushOutputCapture();
2457
+ capturePrepareActive = false;
2458
+ throw err;
2459
+ }
2460
+ }
2461
+ /**
2462
+ * Phase 4b — Evaluate the user-defined `concurrency.group(...)` function with
2463
+ * a timeout, report the resulting key to the orchestrator, and act on the
2464
+ * returned ack. `wait` and `cancel` paths exit the process directly (the run
2465
+ * is over from the runner's perspective). Returns 'proceed' to the caller in
2466
+ * the success path.
2467
+ *
2468
+ * Returns 'failed' instead of exiting when group evaluation throws, so
2469
+ * main() can keep its single exit-on-error point.
2470
+ */
2471
+ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
2472
+ if (!workflow.concurrency?.group) return "proceed";
2473
+ trace("evaluating concurrency group function");
2474
+ const concurrencyTimeoutMs = request.concurrencyEvaluationTimeoutMs ?? 3e4;
2475
+ const groupCtx = {
2476
+ branch: request.branch ?? request.ref,
2477
+ event: request.event ?? {}
2478
+ };
2479
+ try {
2480
+ const ac = new AbortController();
2481
+ const concurrencyTimer = setTimeout(() => ac.abort(), concurrencyTimeoutMs);
2482
+ let groupKey;
2483
+ try {
2484
+ groupKey = await Promise.race([Promise.resolve(workflow.concurrency.group(groupCtx)), new Promise((_, reject) => {
2485
+ ac.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`Concurrency group evaluation timed out after ${concurrencyTimeoutMs}ms`)));
2486
+ })]);
2487
+ } finally {
2488
+ clearTimeout(concurrencyTimer);
2489
+ }
2490
+ trace(`concurrency group evaluated: ${groupKey}`);
2491
+ sendMessage({
2492
+ type: "log.line",
2493
+ stepIndex: -1,
2494
+ line: `[kici] Concurrency group: ${groupKey}`
2495
+ });
2496
+ sendMessage({
2497
+ type: "concurrency.report",
2498
+ group: groupKey
2499
+ });
2500
+ trace("waiting for concurrency ack");
2501
+ let ack = await waitForConcurrencyAck(concurrencyTimeoutMs);
2502
+ trace(`concurrency ack received: action=${ack.action}, reason=${ack.reason ?? "none"}`);
2503
+ if (ack.action === "proceed") {
2504
+ sendMessage({
2505
+ type: "log.line",
2506
+ stepIndex: -1,
2507
+ line: "[kici] Concurrency: proceeding with execution"
2508
+ });
2509
+ return "proceed";
2510
+ }
2511
+ if (ack.action === "wait") {
2512
+ sendMessage({
2513
+ type: "log.line",
2514
+ stepIndex: -1,
2515
+ line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
2516
+ });
2517
+ const waitCapMs = Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5;
2518
+ try {
2519
+ ack = await waitForConcurrencyAck(waitCapMs);
2520
+ } catch (waitErr) {
2521
+ const waitErrMsg = toErrorMessage(waitErr);
2522
+ sendMessage({
2523
+ type: "log.line",
2524
+ stepIndex: -1,
2525
+ line: `[kici] [error] Concurrency wait timed out: ${waitErrMsg}`
2526
+ });
2527
+ sendMessage({
2528
+ type: "job.complete",
2529
+ status: ExecutionJobStatus.enum.failed,
2530
+ stepResults: [],
2531
+ error: `Concurrency wait timed out after ${waitCapMs}ms: ${waitErrMsg}`
2532
+ });
2533
+ process.exit(1);
2534
+ }
2535
+ trace(`concurrency follow-up ack: action=${ack.action}, reason=${ack.reason ?? "none"}`);
2536
+ if (ack.action === "proceed") {
2537
+ sendMessage({
2538
+ type: "log.line",
2539
+ stepIndex: -1,
2540
+ line: "[kici] Concurrency: slot acquired, proceeding with execution"
2541
+ });
2542
+ return "proceed";
2543
+ }
2544
+ if (ack.action === "wait") {
2545
+ sendMessage({
2546
+ type: "log.line",
2547
+ stepIndex: -1,
2548
+ line: "[kici] [error] Concurrency: unexpected second `wait` ack; aborting"
2549
+ });
2550
+ sendMessage({
2551
+ type: "job.complete",
2552
+ status: ExecutionJobStatus.enum.failed,
2553
+ stepResults: [],
2554
+ error: "Concurrency: unexpected second wait ack"
2555
+ });
2556
+ process.exit(1);
2557
+ }
2558
+ }
2559
+ sendMessage({
2560
+ type: "log.line",
2561
+ stepIndex: -1,
2562
+ line: `[kici] Concurrency: cancelled${ack.reason ? ` (${ack.reason})` : ""}`
2563
+ });
2564
+ sendMessage({
2565
+ type: "job.complete",
2566
+ status: ExecutionJobStatus.enum.failed,
2567
+ stepResults: [],
2568
+ error: `Cancelled by concurrency policy${ack.reason ? ": " + ack.reason : ""}`
2569
+ });
2570
+ process.exit(1);
2571
+ } catch (err) {
2572
+ const errMsg = toErrorMessage(err);
2573
+ trace(`concurrency group evaluation failed: ${errMsg}`);
2574
+ sendMessage({
2575
+ type: "log.line",
2576
+ stepIndex: -1,
2577
+ line: `[kici] [error] Concurrency group evaluation failed: ${errMsg}`
2578
+ });
2579
+ sendMessage({
2580
+ type: "job.complete",
2581
+ status: ExecutionJobStatus.enum.failed,
2582
+ stepResults: [],
2583
+ error: `Concurrency group evaluation failed: ${errMsg}`
2584
+ });
2585
+ process.exit(1);
2586
+ }
2587
+ }
2588
+ /**
2589
+ * Phase 5 — Inject env vars and build the `RepoInfo` pair that step contexts
2590
+ * receive when the job is a global workflow. No-op for normal jobs.
2591
+ */
2592
+ function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
2593
+ if (!isGlobal) return void 0;
2594
+ process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
2595
+ process.env.KICI_WORKFLOW_REPO_PATH = workflowDir;
2596
+ process.env.KICI_SOURCE_REPO_PATH = sourceDir;
2597
+ const sourceRepoIdentifier = request.repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
2598
+ process.env.KICI_SOURCE_REPO = sourceRepoIdentifier;
2599
+ process.env.KICI_SOURCE_BRANCH = request.ref;
2600
+ process.env.KICI_SOURCE_SHA = request.sha;
2601
+ process.env.KICI_WORKFLOW_REPO = request.workflowRepoIdentifier ?? "";
2602
+ trace(`global workflow env vars injected: KICI_WORKFLOW_REPO_PATH=${workflowDir}, KICI_SOURCE_REPO_PATH=${sourceDir}`);
2603
+ return {
2604
+ workflowRepo: {
2605
+ identifier: request.workflowRepoIdentifier ?? "",
2606
+ path: workflowDir,
2607
+ ref: request.workflowRef,
2608
+ sha: request.workflowSha
2609
+ },
2610
+ sourceRepo: {
2611
+ identifier: sourceRepoIdentifier,
2612
+ path: sourceDir,
2613
+ ref: request.ref,
2614
+ sha: request.sha
2615
+ }
2616
+ };
2617
+ }
2618
+ /**
2619
+ * Phase 8 — Cancel-path hook execution. Runs the four cancel hooks
2620
+ * (step onCancel → step cleanup → job onCancel → job cleanup) inside-out,
2621
+ * accumulating any failure reasons into a compound `cancelFailureReason`.
2622
+ *
2623
+ * `forceAborted=true` short-circuits and runs none of the hooks; the caller
2624
+ * still flips finalStatus to failed (the fork-runner overrides it back to
2625
+ * `cancelled` based on its own state machine).
2626
+ */
2627
+ async function runCancelPathHooks(args) {
2628
+ const { forceAborted, normalizedSteps, loopResult, jobHooks, jobStartTime, outputsMap, createStepCtxWithCapture, disposeStepResources, maskedSend } = args;
2629
+ if (forceAborted) {
2630
+ maskedSend({
2631
+ type: "log.line",
2632
+ stepIndex: -1,
2633
+ line: "[kici] Force cancel received, skipping all hooks"
2634
+ });
2635
+ return { finalStatus: ExecutionJobStatus.enum.failed };
2636
+ }
2637
+ maskedSend({
2638
+ type: "log.line",
2639
+ stepIndex: -1,
2640
+ line: "[kici] Cancel received, running cancel hooks..."
2641
+ });
2642
+ const cancelOutcome = buildOutcomeMetadata({
2643
+ status: ExecutionJobStatus.enum.cancelled,
2644
+ reason: "Job cancelled",
2645
+ stepOutputs: Object.fromEntries(outputsMap),
2646
+ startTime: jobStartTime
2647
+ });
2648
+ let hookStepIndex = normalizedSteps.length + loopResult.stepResults.length;
2649
+ let cancelFailureReason;
2650
+ const concatReason = (existing, fragment) => existing ? `${existing}; ${fragment}` : fragment;
2651
+ const runHook = async (hook, label, hookType, failedStep) => {
2652
+ maskedSend({
2653
+ type: "log.line",
2654
+ stepIndex: -1,
2655
+ line: `[kici] Running ${label} hook...`
2656
+ });
2657
+ const ctx = createStepCtxWithCapture(hookStepIndex, label);
2658
+ let hookResult;
2659
+ try {
2660
+ hookResult = await executeHook({
2661
+ hook,
2662
+ stepContext: ctx,
2663
+ outcome: failedStep ? {
2664
+ ...cancelOutcome,
2665
+ failedStep
2666
+ } : cancelOutcome,
2667
+ hookType,
2668
+ stepIndex: hookStepIndex,
2669
+ sendIpc: maskedSend
2670
+ });
2671
+ } finally {
2672
+ await disposeStepResources();
2673
+ }
2674
+ hookStepIndex++;
2675
+ if (!hookResult.success) {
2676
+ const fragment = label === "onCancel" ? `cancelled (onCancel hook failed: ${hookResult.error})` : label.endsWith(":onCancel") ? `cancelled (step onCancel hook failed: ${hookResult.error})` : label.endsWith(":cleanup") ? `step cleanup hook failed: ${hookResult.error}` : label === "cleanup" ? `cleanup hook failed: ${hookResult.error}` : `${label} hook failed: ${hookResult.error}`;
2677
+ cancelFailureReason = concatReason(cancelFailureReason, fragment);
2678
+ maskedSend({
2679
+ type: "log.line",
2680
+ stepIndex: -1,
2681
+ line: `[kici] ${label} hook failed: ${hookResult.error}`
2682
+ });
2683
+ } else maskedSend({
2684
+ type: "log.line",
2685
+ stepIndex: -1,
2686
+ line: `[kici] ${label} hook completed`
2687
+ });
2688
+ };
2689
+ const lastStepIndex = loopResult.stepResults.length - 1;
2690
+ const lastStep = lastStepIndex >= 0 ? normalizedSteps[lastStepIndex] : void 0;
2691
+ if (lastStep?.onCancel) await runHook(lastStep.onCancel, `${lastStep.name}:onCancel`, "onCancel", lastStep.name);
2692
+ if (lastStep?.cleanup) await runHook(lastStep.cleanup, `${lastStep.name}:cleanup`, "cleanup", lastStep.name);
2693
+ if (jobHooks.onCancel) await runHook(jobHooks.onCancel, "onCancel", "onCancel");
2694
+ if (jobHooks.cleanup) await runHook(jobHooks.cleanup, "cleanup", "cleanup");
2695
+ maskedSend({
2696
+ type: "log.line",
2697
+ stepIndex: -1,
2698
+ line: `[kici] Cancel complete, job status: ${cancelFailureReason ? "failed" : "cancelled"}`
2699
+ });
2700
+ return {
2701
+ finalStatus: ExecutionJobStatus.enum.failed,
2702
+ cancelFailureReason
2703
+ };
2704
+ }
2705
+ /**
2706
+ * Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
2707
+ * factory when `dynamicSource` is set, otherwise looking up the static job)
2708
+ * and normalise the result into the `Step[]` shape the step loop consumes.
2709
+ * Bare-function steps get auto-generated `step-N` names so the IPC reporting
2710
+ * and the StepRefMap (used by `.result` proxies) line up.
2711
+ */
2712
+ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
2713
+ let rawSteps;
2714
+ let driftDroppedJobs = [];
2715
+ if (request.dynamicSource) {
2716
+ const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames);
2717
+ rawSteps = dynamicResult.steps;
2718
+ driftDroppedJobs = dynamicResult.droppedJobs;
2719
+ if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
2720
+ } else rawSteps = extractSteps(workflow, request.jobName);
2721
+ const refMap = /* @__PURE__ */ new WeakMap();
2722
+ let stepCounter = 0;
2723
+ return {
2724
+ normalizedSteps: rawSteps.map((stepOrFn) => {
2725
+ if (typeof stepOrFn === "function") {
2726
+ stepCounter++;
2727
+ const name = `step-${stepCounter}`;
2728
+ refMap.set(stepOrFn, name);
2729
+ return {
2730
+ _tag: "Step",
2731
+ name,
2732
+ run: stepOrFn,
2733
+ outputs: void 0
2734
+ };
2735
+ }
2736
+ const s = stepOrFn;
2737
+ if (!s.name) {
2738
+ stepCounter++;
2739
+ return {
2740
+ ...s,
2741
+ name: `step-${stepCounter}`
2742
+ };
2743
+ }
2744
+ return s;
2745
+ }),
2746
+ refMap,
2747
+ driftDroppedJobs
2748
+ };
2749
+ }
2750
+ /**
2751
+ * Phase 6 — Build the output infrastructure: the per-step operator-secret
2752
+ * key set (used for setEnv override protection), and the output / job-output
2753
+ * maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
2754
+ * Sets the SDK module globals as a side effect.
2755
+ */
2756
+ function buildOutputInfrastructure(request, refMap) {
2757
+ const operatorSecretKeys = /* @__PURE__ */ new Set();
2758
+ if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
2759
+ if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
2760
+ const outputsMap = /* @__PURE__ */ new Map();
2761
+ const secretOutputs = /* @__PURE__ */ new Map();
2762
+ setStepOutputsMap(outputsMap);
2763
+ setStepRefMap(refMap);
2764
+ const jobOutputsMap = /* @__PURE__ */ new Map();
2765
+ if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
2766
+ setJobOutputsMap(jobOutputsMap);
2767
+ return {
2768
+ operatorSecretKeys,
2769
+ outputsMap,
2770
+ secretOutputs,
2771
+ jobOutputsMap
2772
+ };
2773
+ }
2774
+ /**
2775
+ * Phase 7 — Evaluate job-level rules. When any rule fails, send a
2776
+ * `job.complete{success}` with all steps marked skipped and exit 0.
2777
+ * Returns false when the caller should continue to step execution.
2778
+ */
2779
+ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
2780
+ if (!job?.rules || job.rules.length === 0) return false;
2781
+ const ruleCtx = createRuleContext(request.event ?? {}, [], process.env);
2782
+ if ((await evaluateRules(job.rules, ruleCtx, request.jobName)).allPassed) return false;
2783
+ const skippedResults = normalizedSteps.map((s, i) => ({
2784
+ name: s.name,
2785
+ stepIndex: i,
2786
+ status: ExecutionStepStatus.enum.skipped,
2787
+ durationMs: 0
2788
+ }));
2789
+ flushOutputCapture();
2790
+ capturePrepareActive = false;
2791
+ sendMessage({
2792
+ type: "job.complete",
2793
+ status: ExecutionJobStatus.enum.success,
2794
+ stepResults: skippedResults
2795
+ });
2796
+ process.exit(0);
2797
+ }
2798
+ /**
2799
+ * Collect the six job-level hooks (beforeStep / afterStep / onSuccess /
2800
+ * onFailure / onCancel / cleanup) into a single typed object the step loop
2801
+ * and cancel-path consume.
2802
+ */
2803
+ function collectJobHooks(job) {
2804
+ const jobHooks = {};
2805
+ if (!job) return jobHooks;
2806
+ if (job.beforeStep) jobHooks.beforeStep = job.beforeStep;
2807
+ if (job.afterStep) jobHooks.afterStep = job.afterStep;
2808
+ if (job.onSuccess) jobHooks.onSuccess = job.onSuccess;
2809
+ if (job.onFailure) jobHooks.onFailure = job.onFailure;
2810
+ if (job.onCancel) jobHooks.onCancel = job.onCancel;
2811
+ if (job.cleanup) jobHooks.cleanup = job.cleanup;
2812
+ return jobHooks;
2813
+ }
2814
+ /**
2815
+ * Run the complete job execution lifecycle.
2816
+ *
2817
+ * 1. Receive execution request
2818
+ * 2. Git clone (if checkout enabled)
2819
+ * 3. Dependency handling (cache restore or inline install)
2820
+ * 4. Load workflow module (from bundle or source)
2821
+ * 5. Extract workflow and steps
2822
+ * 6. Evaluate rules (if any)
2823
+ * 7. Execute steps sequentially with IPC reporting
2824
+ * 8. Send job.complete and exit
2825
+ */
2826
+ async function main() {
2827
+ trace(`main() started, isForkMode=${isForkMode}, pid=${process.pid}`);
2828
+ sendMessage({ type: "ready" });
2829
+ trace("ready message sent");
2830
+ const request = (await receiveRequest()).request;
2831
+ trace(`execute request received: workDir=${request.workDir}, workflow=${request.workflowName}, job=${request.jobName}`);
2832
+ const workDir = request.workDir;
2833
+ const defaultTimeoutMs = request.defaultStepTimeoutMs ?? 1800 * 1e3;
2834
+ const isGlobal = request.isGlobalWorkflow === true;
2835
+ const workflowDir = isGlobal ? join(workDir, "workflow") : workDir;
2836
+ const sourceDir = isGlobal ? join(workDir, "source") : workDir;
2837
+ const masker = createSecretMasker(request);
2838
+ const maskedSend = (msg) => {
2839
+ if (msg.type === "log.line" && masker.hasSecrets()) sendMessage({
2840
+ ...msg,
2841
+ line: masker.mask(msg.line)
2842
+ });
2843
+ else sendMessage(msg);
2844
+ };
2845
+ await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
2846
+ await applyOverlayIfRequested(request, workflowDir);
2847
+ if (aborted) abortAndExit("aborted after clone");
2848
+ await installDependenciesIfNeeded(workflowDir, request);
2849
+ if (aborted) abortAndExit("aborted after deps");
2850
+ await restoreSourceTarballIfRequested(workflowDir, request);
2851
+ const module = (await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend)).module;
2852
+ const workflow = extractWorkflow(module, request.workflowName);
2853
+ await evaluateConcurrencyGroupIfPresent(workflow, request);
2854
+ const apiTransport = async (method, params) => {
2855
+ const reqId = randomUUID();
2856
+ sendMessage({
2857
+ type: "agent.api.request",
2858
+ requestId: reqId,
2859
+ method,
2860
+ params: params ?? {}
2861
+ });
2862
+ return waitForApiResponse(reqId);
2863
+ };
2864
+ const { normalizedSteps, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
2865
+ const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
2866
+ const job = findJob(workflow, request.jobName);
2867
+ await maybeSkipJobOnRules(job, request, normalizedSteps);
2868
+ if (aborted) abortAndExit("aborted after rules");
2869
+ const jobHooks = collectJobHooks(job);
2870
+ const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
2871
+ flushOutputCapture();
2872
+ capturePrepareActive = false;
2873
+ const stepCwd = sourceDir;
2874
+ let currentStepSecrets = null;
2875
+ let currentStepDispose = null;
2876
+ const createStepCtxWithCapture = (stepIndex, stepName) => {
2877
+ captureStepIndex = stepIndex;
2878
+ const handle = buildStepSecrets(request, masker, () => {});
2879
+ currentStepSecrets = handle.secrets;
2880
+ currentStepDispose = handle.dispose;
2881
+ const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets);
2882
+ if (globalRepoInfo) {
2883
+ ctx.workflowRepo = globalRepoInfo.workflowRepo;
2884
+ ctx.sourceRepo = globalRepoInfo.sourceRepo;
2885
+ }
2886
+ return ctx;
2887
+ };
2888
+ const jobStartTime = Date.now();
2889
+ const loopResult = await executeStepLoop({
2890
+ steps: normalizedSteps,
2891
+ createStepContext: createStepCtxWithCapture,
2892
+ sendIpc: maskedSend,
2893
+ defaultTimeoutMs,
2894
+ outputsMap,
2895
+ event: request.event ?? {},
2896
+ env: process.env,
2897
+ jobHooks,
2898
+ isAborted: () => aborted,
2899
+ startTime: jobStartTime,
2900
+ getSecretsAccessLog: () => {
2901
+ flushOutputCapture();
2902
+ captureStepIndex = -1;
2903
+ return currentStepSecrets?.getAccessLog() ?? [];
2904
+ },
2905
+ getSecretMountRecords: () => {
2906
+ return currentStepSecrets ? [...currentStepSecrets.getMountRecords()] : [];
2907
+ },
2908
+ disposeStepResources: async () => {
2909
+ const disposeFn = currentStepDispose;
2910
+ currentStepDispose = null;
2911
+ currentStepSecrets = null;
2912
+ if (disposeFn) await disposeFn();
2913
+ }
2914
+ });
2915
+ let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
2916
+ let cancelFailureReason;
2917
+ if (aborted) {
2918
+ const cancelResult = await runCancelPathHooks({
2919
+ forceAborted,
2920
+ normalizedSteps,
2921
+ loopResult,
2922
+ jobHooks,
2923
+ jobStartTime,
2924
+ outputsMap,
2925
+ createStepCtxWithCapture,
2926
+ disposeStepResources: async () => {
2927
+ const disposeFn = currentStepDispose;
2928
+ currentStepDispose = null;
2929
+ currentStepSecrets = null;
2930
+ if (disposeFn) await disposeFn();
2931
+ },
2932
+ maskedSend
2933
+ });
2934
+ finalStatus = cancelResult.finalStatus;
2935
+ cancelFailureReason = cancelResult.cancelFailureReason;
2936
+ }
2937
+ const aggregatedOutputs = {};
2938
+ for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
2939
+ sendMessage({
2940
+ type: "job.complete",
2941
+ status: finalStatus,
2942
+ stepResults: loopResult.stepResults,
2943
+ ...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
2944
+ ...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
2945
+ ...loopResult.failureReason && { error: loopResult.failureReason },
2946
+ ...cancelFailureReason && { error: cancelFailureReason },
2947
+ ...driftDroppedJobs.length > 0 && { droppedJobs: driftDroppedJobs }
2948
+ });
2949
+ process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
2950
+ }
2951
+ /**
2952
+ * Find a static Job by name in the workflow.
2953
+ */
2954
+ function findJob(workflow, jobName) {
2955
+ for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item;
2956
+ }
2957
+ main().catch((error) => {
2958
+ const message = toErrorMessage(error);
2959
+ const stack = error instanceof Error ? error.stack : void 0;
2960
+ process.stderr.write(`[workflow-runner] Fatal error: ${message}\n`);
2961
+ if (stack) process.stderr.write(`[workflow-runner] Stack: ${stack}\n`);
2962
+ sendMessage({
2963
+ type: "log.line",
2964
+ stepIndex: -1,
2965
+ line: `[workflow-runner] [error] Fatal: ${message}`
2966
+ });
2967
+ sendMessage({
2968
+ type: "job.complete",
2969
+ status: ExecutionJobStatus.enum.failed,
2970
+ stepResults: [],
2971
+ error: message
2972
+ });
2973
+ setTimeout(() => process.exit(1), 100);
2974
+ });
2975
+ //#endregion
2976
+ export {};
2977
+
2978
+ //# sourceMappingURL=workflow-runner.js.map