@kici-dev/agent 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/config.d.ts +38 -38
  2. package/dist/eval-runner.js +1866 -0
  3. package/dist/execution/dep-installer.d.ts +28 -7
  4. package/dist/execution/eval-context.d.ts +114 -0
  5. package/dist/execution/global-eval-types.d.ts +26 -0
  6. package/dist/execution/job-runner.d.ts +23 -75
  7. package/dist/execution/npm-registry-config.d.ts +6 -0
  8. package/dist/execution/rule-evaluator.d.ts +2 -1
  9. package/dist/execution/sandbox/bare-metal-sandbox.d.ts +6 -0
  10. package/dist/execution/sandbox/container-hardening.d.ts +8 -0
  11. package/dist/execution/sandbox/container-sandbox.d.ts +9 -0
  12. package/dist/execution/sandbox/eval-dispatch.d.ts +28 -0
  13. package/dist/execution/sandbox/eval-fork-runner.d.ts +43 -0
  14. package/dist/execution/sandbox/eval-runner.d.ts +21 -0
  15. package/dist/execution/sandbox/fork-runner.d.ts +23 -0
  16. package/dist/execution/sandbox/ipc-protocol.d.ts +82 -8
  17. package/dist/execution/sandbox/job-network.d.ts +91 -0
  18. package/dist/execution/sandbox/log-masker.d.ts +38 -0
  19. package/dist/execution/sandbox/types.d.ts +6 -0
  20. package/dist/execution/sandbox/workflow-runner.d.ts +1 -1
  21. package/dist/execution/source-packer.d.ts +4 -4
  22. package/dist/execution/source-restore.d.ts +28 -13
  23. package/dist/execution/workflow-loader.d.ts +16 -13
  24. package/dist/execution/yarnrc-berry-config.d.ts +6 -4
  25. package/dist/index.js +83 -40
  26. package/dist/provenance/statement-builder.d.ts +19 -8
  27. package/dist/server.js +1530 -1666
  28. package/dist/workflow-runner-bundle.js +1129 -192
  29. package/dist/workflow-runner.js +395 -149
  30. package/dist/ws/orchestrator-client.d.ts +4 -0
  31. package/package.json +6 -5
  32. package/sbom.spdx.json +66 -66
package/dist/server.js CHANGED
@@ -2,7 +2,7 @@ import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
2
2
  import { dirname as __cjs_dirname } from "node:path";
3
3
  const __filename = __cjs_fileURLToPath(import.meta.url);
4
4
  __cjs_dirname(__filename);
5
- import { createRequire, register } from "node:module";
5
+ import "node:module";
6
6
  import crypto$1, { createCipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
7
7
  import * as os$1 from "node:os";
8
8
  import os, { hostname, tmpdir } from "node:os";
@@ -10,18 +10,18 @@ import { PassThrough, Readable, Transform, Writable } from "node:stream";
10
10
  import { serve } from "@hono/node-server";
11
11
  import { Hono } from "hono";
12
12
  import winston from "winston";
13
- import { AgentDeliveryMode, AgentPlatform, RingBuffer, addLogsToArchive, chunkBuffer, createHealthRoutes, createLogger, createMeter, createMetricsRoutes, deriveSharedSecret, getPrometheusExporter, getReconnectDelay, getRequestContext, guardStartup, initTelemetry, kiciTmpBase, logger, normalizeLineEndings, redactConfig, requestContext, setServiceName, setupGracefulShutdown, sha256, sha256File, toErrorMessage, validateRequiredTools } from "@kici-dev/shared";
13
+ import { AgentDeliveryMode, AgentPlatform, RingBuffer, addLogsToArchive, chunkBuffer, createHealthRoutes, createLogger, createMeter, createMetricsRoutes, deriveSharedSecret, getPrometheusExporter, getReconnectDelay, getRequestContext, guardStartup, initTelemetry, kiciTmpBase, logger, redactConfig, requestContext, setServiceName, setupGracefulShutdown, sha256, sha256File, toErrorMessage, validateRequiredTools } from "@kici-dev/shared";
14
14
  import { z } from "zod";
15
15
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
16
- import { ALLOWED_SYSTEM_VARS, ArtifactCompleteAckOutcome, CacheRunEventType, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, InitFailureCategory, KNOWN_ROLES, LogStream, MAX_MATRIX_MATERIALIZATION, MatrixShapeError, PROTOCOL_VERSION, RuntimeFact, SANDBOX_DEFAULT_VARS, SetupStepType, WS_CLOSE_AGENT_AUTH_FAILED, WS_MAX_PAYLOAD_BYTES, applyIncludeExclude, buildTrustedPassthroughEnv, deriveOsArchLabels, expandMatrix, hasOrchAgentCapability, heartbeatSchema, hostLabel, matrixCombinationCount, mergeAutoLabels, orchestratorToAgentMessageSchema, parseHostPropertyAssignments, resolveRoleLabels, resolveWhenToRunOn, runtimeLabel, validateNoReservedLabels } from "@kici-dev/engine";
16
+ import { ALLOWED_SYSTEM_VARS, ArtifactCompleteAckOutcome, CacheRunEventType, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, InitFailureCategory, KNOWN_ROLES, LogStream, PROTOCOL_VERSION, RuntimeFact, SANDBOX_DEFAULT_VARS, SetupStepType, WS_CLOSE_AGENT_AUTH_FAILED, WS_MAX_PAYLOAD_BYTES, buildTrustedPassthroughEnv, deriveOsArchLabels, hasOrchAgentCapability, heartbeatSchema, hostLabel, mergeAutoLabels, orchestratorToAgentMessageSchema, parseHostPropertyAssignments, resolveRoleLabels, runtimeLabel, validateNoReservedLabels } from "@kici-dev/engine";
17
17
  import { execFile, execFileSync, execSync, fork, spawn } from "node:child_process";
18
- import fsPromises, { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, unlink, writeFile } from "node:fs/promises";
18
+ import fsPromises, { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
19
19
  import * as fs$1 from "node:fs";
20
20
  import fs, { createReadStream, createWriteStream, existsSync } from "node:fs";
21
21
  import path, { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import WebSocket from "ws";
23
23
  import { ZipArchive } from "archiver";
24
- import { fileURLToPath, pathToFileURL } from "node:url";
24
+ import { fileURLToPath } from "node:url";
25
25
  import { AsyncLocalStorage } from "node:async_hooks";
26
26
  import { format, promisify } from "node:util";
27
27
  import { OTEL_DATA_POINT_TYPE, mapDataPointTypeToWireKind } from "@kici-dev/engine/metrics/metric-kind-compat";
@@ -30,18 +30,20 @@ import { makeTempDir } from "@kici-dev/core/tmp";
30
30
  import Docker from "dockerode";
31
31
  import { GIT_CREDENTIAL_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/git-credential-relay";
32
32
  import { createServer } from "node:net";
33
- import { buildKiciApi, buildNeedsContext, createFilterContext, isDynamicGroupRef, isDynamicJobFn, isParallelGroup, isStaticArray, isStaticObject, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
34
- import { $ } from "zx";
35
33
  import { c, x } from "tar";
34
+ import { KICI_SOURCE_EXCLUDED_PREFIX } from "@kici-dev/core/kici-source-digest";
36
35
  import https from "node:https";
37
36
  import http from "node:http";
38
37
  import { pipeline } from "node:stream/promises";
39
38
  import { createGunzip } from "node:zlib";
40
- import { normalizeRunsOnToMatchers } from "@kici-dev/engine/labels/compile";
39
+ import "@kici-dev/sdk";
40
+ import "@kici-dev/engine/labels/compile";
41
+ import { buildNeedsContext } from "@kici-dev/sdk/internal";
41
42
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
42
43
  import { parse, stringify } from "yaml";
43
44
  import { createInterface } from "node:readline";
44
45
  import { RuntimeSubtree, ensureRuntimeVolume } from "@kici-dev/shared/container-runtime";
46
+ import { JOB_NETWORK_SUBNET, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, ensureKiciTable, readForwardChain, readInputChain, removeIsolationRules, validateNftablesAvailability } from "@kici-dev/shared/net";
45
47
  var __defProp = Object.defineProperty;
46
48
  var __esmMin = (fn, res, err) => () => {
47
49
  if (err) throw err[0];
@@ -292,6 +294,10 @@ const configSchema = z.object({
292
294
  sandboxHardened: z.string().default("true").transform((s) => s !== "false"),
293
295
  sandboxReadonlyRootfs: z.string().default("false").transform((s) => s === "true"),
294
296
  sandboxUser: z.string().optional(),
297
+ runnerUser: z.string().optional(),
298
+ runnerDebugStdio: z.string().default("false").transform((s) => s === "true"),
299
+ sandboxNetworkIsolation: z.string().default("true").transform((s) => s !== "false"),
300
+ allowInstallScripts: z.string().default("false").transform((s) => s === "true"),
295
301
  sandboxPidsLimit: z.coerce.number().int().positive().default(512),
296
302
  sandboxMemoryBytes: z.coerce.number().int().positive().default(2147483648),
297
303
  sandboxNanoCpus: z.coerce.number().int().positive().default(2e9),
@@ -346,6 +352,10 @@ const envDef = defineEnv({
346
352
  sandboxHardened: "KICI_SANDBOX_HARDENED",
347
353
  sandboxReadonlyRootfs: "KICI_SANDBOX_READONLY_ROOTFS",
348
354
  sandboxUser: "KICI_SANDBOX_USER",
355
+ runnerUser: "KICI_RUNNER_USER",
356
+ runnerDebugStdio: "KICI_RUNNER_DEBUG_STDIO",
357
+ sandboxNetworkIsolation: "KICI_SANDBOX_NETWORK_ISOLATION",
358
+ allowInstallScripts: "KICI_ALLOW_INSTALL_SCRIPTS",
349
359
  sandboxPidsLimit: "KICI_SANDBOX_PIDS_LIMIT",
350
360
  sandboxMemoryBytes: "KICI_SANDBOX_MEMORY_BYTES",
351
361
  sandboxNanoCpus: "KICI_SANDBOX_NANO_CPUS",
@@ -399,6 +409,10 @@ const envDef = defineEnv({
399
409
  * - KICI_SCALER_CLAIM_CODE (optional single-use claim code the agent exchanges for its own ephemeral credentials before registering; ignored when KICI_AGENT_TOKEN is set)
400
410
  * - KICI_SCALER_IDLE_TIMEOUT (ms, default 5000) — how long a scaler-managed agent waits before shutdown after going idle
401
411
  * - KICI_SCALER_PENDING_DISPATCH_TIMEOUT (ms, default 60000) — extended idle window when register.ack signals a queued bound job
412
+ * - KICI_RUNNER_USER (optional, uid | uid:gid | name) — run the bare-metal runner child as a dedicated user instead of the agent's own
413
+ * - KICI_ALLOW_INSTALL_SCRIPTS (default: false) — re-enable package lifecycle scripts during the `.kici/` dependency install
414
+ * - KICI_SANDBOX_NETWORK_ISOLATION (default: true) — RFC1918 + cloud-metadata egress filtering for nested job containers
415
+ * - KICI_RUNNER_DEBUG_STDIO (default: false) — echo the runner child's raw stdio onto the agent's stderr
402
416
  * - KICI_EXECUTION_MODE (optional, options: container | bare-metal | firecracker) — override the runner's mode-pick logic
403
417
  * - KICI_CONCURRENCY_WAIT_TIMEOUT_MS (default: 3_600_000) — workflow-runner timeout when long-polling for a slot-release follow-up `concurrency.ack`
404
418
  * - KICI_AGENT_BETWEEN_JOBS_RESET_COMMAND (optional) — host-reset command run between jobs on a reused agent (fail-open)
@@ -407,16 +421,85 @@ const envDef = defineEnv({
407
421
  * - KICI_AGENT_ORPHAN_CLEANUP (default: true) — reap a finished job's leaked process tree (bare-metal); false = only signal the runner child
408
422
  * - KICI_AGENT_DRAIN_ON_RESET_FAILURE (default: false) — drain the agent after repeated consecutive reset failures
409
423
  */
424
+ /**
425
+ * Env vars the boot-time unknown-KICI_* validator tolerates **only** under
426
+ * `NODE_ENV=test`. `KICI_SKIP_DB_TESTS` is the vitest Postgres harness's
427
+ * opt-out (`scripts/db-test-postgres.ts`), so a suite run with it set must not
428
+ * fail the agent's own config tests. Mirrors the orchestrator's list, which
429
+ * already carries it. Under any other NODE_ENV the allowance is empty, so a
430
+ * production boot with a stray one still fails the validator.
431
+ */
432
+ const TEST_ONLY_ENV_VARS = ["KICI_SKIP_DB_TESTS"];
410
433
  function loadConfig() {
411
434
  const data = envDef.parse();
412
435
  if (!data.scalerManaged && !data.agentToken) validateNoReservedLabels(data.labels, "KICI_LABELS");
413
- validateUnknownKiciVars([...envDef.listKnownEnvVars(), ...LOGGER_ENV_VARS]);
436
+ validateUnknownKiciVars([
437
+ ...envDef.listKnownEnvVars(),
438
+ ...LOGGER_ENV_VARS,
439
+ ...process.env.NODE_ENV === "test" ? TEST_ONLY_ENV_VARS : []
440
+ ]);
414
441
  return {
415
442
  ...data,
416
443
  agentId: data.agentId ?? `${hostname()}-${randomUUID().slice(0, 8)}`
417
444
  };
418
445
  }
419
446
  /**
447
+ * Environment variables holding an agent credential, removed from the agent's
448
+ * own `process.env` once `loadConfig()` has read them into the config object.
449
+ *
450
+ * `KICI_ORCHESTRATOR_URL` is deliberately NOT here — it is a URL, not a
451
+ * credential, and `resolveOrchestratorUrl` reads it from the environment in both
452
+ * the agent and the runner child.
453
+ */
454
+ const CREDENTIAL_ENV_VARS = [
455
+ "KICI_AGENT_TOKEN",
456
+ "KICI_SCALER_CLAIM_CODE",
457
+ "KICI_GITHUB_TOKEN"
458
+ ];
459
+ /**
460
+ * Remove the agent's own credentials from `process.env` after boot.
461
+ *
462
+ * The runner child runs as the agent's user under the default bare-metal
463
+ * backend, so `/proc/<agent-pid>/environ` is readable from inside a job: a step
464
+ * could print the agent token, register a rogue agent with it, and receive every
465
+ * dispatch routed to this one. Stripping the variables closes that read on every
466
+ * platform, whatever backend is selected.
467
+ *
468
+ * This is defence in depth, not isolation — a same-uid `ptrace` or
469
+ * `/proc/<pid>/mem` read still reaches the value the config object holds. The
470
+ * boundary is `KICI_RUNNER_USER` (a dedicated uid) or the container backend.
471
+ *
472
+ * Safe because each variable has exactly one reader: `loadConfig()`, which has
473
+ * one caller and runs before this. Returns the names actually removed.
474
+ */
475
+ function scrubCredentialEnv() {
476
+ const removed = [];
477
+ for (const name of CREDENTIAL_ENV_VARS) if (process.env[name] !== void 0) {
478
+ delete process.env[name];
479
+ removed.push(name);
480
+ }
481
+ return removed;
482
+ }
483
+ /**
484
+ * Describe the runner-isolation posture when it offers no boundary between a
485
+ * job's step code and the agent process, or `undefined` when one is in place.
486
+ *
487
+ * The exposed posture is the default bare-metal one: `KICI_SANDBOX=false` (a
488
+ * plain `fork()`, no namespaces), no `KICI_RUNNER_USER`, and no container
489
+ * backend. The child then runs as the agent's own user, so the env allowlist
490
+ * that keeps the agent's credentials out of the child is not a credential
491
+ * boundary — anything running as that uid can still reach the agent process.
492
+ *
493
+ * Reported as one startup warning naming both remedies, rather than refused:
494
+ * bwrap is Linux-only and the container backend imposes a container runtime, so
495
+ * neither can be a default without breaking existing installs.
496
+ */
497
+ function describeExposedPosture(config) {
498
+ if (config.sandbox || config.runnerUser) return void 0;
499
+ if (config.executionMode === "container" && !config.jobImageAgent) return void 0;
500
+ return "Runner isolation is OFF: job steps run as this agent's own user, so a step can read the agent process (for example /proc/<pid>/mem) despite the environment allowlist. Set KICI_RUNNER_USER=<user> to give the runner child a dedicated uid, or KICI_SANDBOX=true (Linux, bubblewrap) for namespace isolation.";
501
+ }
502
+ /**
420
503
  * Connection/identity options for the orchestrator WebSocket client, derived
421
504
  * purely from config.
422
505
  *
@@ -548,7 +631,7 @@ async function buildAgentMiniBundle(opts) {
548
631
  * name is '@kici-dev/agent'. This is robust to the agent's bundled dist layout
549
632
  * (build-service.mjs emits dist/index.js / dist/server.js, so a fixed relative
550
633
  * depth would be wrong). Returns null when it can't be resolved so the
551
- * agent.register message simply omits the field rather than failing.
634
+ * agent.register message omits the field rather than failing.
552
635
  */
553
636
  function readAgentVersion() {
554
637
  try {
@@ -600,7 +683,7 @@ var LogBuffer = class extends RingBuffer {
600
683
  };
601
684
  //#endregion
602
685
  //#region src/ws/orchestrator-client.ts
603
- const logger$14 = createLogger({ prefix: "orchestrator-client" });
686
+ const logger$16 = createLogger({ prefix: "orchestrator-client" });
604
687
  /**
605
688
  * How long to wait for `artifacts.upload.complete.ack` before failing the step.
606
689
  *
@@ -779,7 +862,7 @@ var OrchestratorClient = class OrchestratorClient {
779
862
  */
780
863
  connect() {
781
864
  if (this._state !== "disconnected") {
782
- logger$14.warn("connect() called while not disconnected", { state: this._state });
865
+ logger$16.warn("connect() called while not disconnected", { state: this._state });
783
866
  return;
784
867
  }
785
868
  this.intentionalDisconnect = false;
@@ -1376,7 +1459,7 @@ var OrchestratorClient = class OrchestratorClient {
1376
1459
  }
1377
1460
  });
1378
1461
  } catch (err) {
1379
- logger$14.error("Failed to create WebSocket", { error: toErrorMessage(err) });
1462
+ logger$16.error("Failed to create WebSocket", { error: toErrorMessage(err) });
1380
1463
  this._state = "disconnected";
1381
1464
  this.scheduleReconnect();
1382
1465
  return;
@@ -1388,12 +1471,12 @@ var OrchestratorClient = class OrchestratorClient {
1388
1471
  this.handleMessage(data);
1389
1472
  });
1390
1473
  this.ws.on("close", (code, reason) => {
1391
- logger$14.info("Orchestrator connection closed", {
1474
+ logger$16.info("Orchestrator connection closed", {
1392
1475
  code,
1393
1476
  reason: reason.toString()
1394
1477
  });
1395
1478
  if (code === WS_CLOSE_AGENT_AUTH_FAILED) {
1396
- logger$14.error("Orchestrator closed with auth-failed code -- token is invalid or revoked. NOT retrying.", {
1479
+ logger$16.error("Orchestrator closed with auth-failed code -- token is invalid or revoked. NOT retrying.", {
1397
1480
  code,
1398
1481
  reason: reason.toString()
1399
1482
  });
@@ -1432,7 +1515,7 @@ var OrchestratorClient = class OrchestratorClient {
1432
1515
  if (!this.intentionalDisconnect) this.scheduleReconnect();
1433
1516
  });
1434
1517
  this.ws.on("error", (err) => {
1435
- logger$14.error(`Orchestrator WebSocket error: ${err.message}`);
1518
+ logger$16.error(`Orchestrator WebSocket error: ${err.message}`);
1436
1519
  if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close();
1437
1520
  });
1438
1521
  }
@@ -1456,7 +1539,7 @@ var OrchestratorClient = class OrchestratorClient {
1456
1539
  }
1457
1540
  if (this.token) {
1458
1541
  this._state = "authenticating";
1459
- logger$14.info("Connected to orchestrator, sending auth.request", {
1542
+ logger$16.info("Connected to orchestrator, sending auth.request", {
1460
1543
  url: this.url,
1461
1544
  agentId: this.agentId
1462
1545
  });
@@ -1467,7 +1550,7 @@ var OrchestratorClient = class OrchestratorClient {
1467
1550
  }));
1468
1551
  } else {
1469
1552
  this._state = "registering";
1470
- logger$14.info("Connected to orchestrator, sending agent.register (no token)", {
1553
+ logger$16.info("Connected to orchestrator, sending agent.register (no token)", {
1471
1554
  url: this.url,
1472
1555
  agentId: this.agentId
1473
1556
  });
@@ -1481,7 +1564,7 @@ var OrchestratorClient = class OrchestratorClient {
1481
1564
  * already been torn down with no reconnect).
1482
1565
  */
1483
1566
  async performClaimExchange(claimCode) {
1484
- logger$14.info("Self-bootstrap: exchanging claim code for ephemeral credentials", {
1567
+ logger$16.info("Self-bootstrap: exchanging claim code for ephemeral credentials", {
1485
1568
  url: this.url,
1486
1569
  agentId: this.agentId
1487
1570
  });
@@ -1500,7 +1583,7 @@ var OrchestratorClient = class OrchestratorClient {
1500
1583
  this.token = agentToken;
1501
1584
  this.agentId = agentId;
1502
1585
  this.labels = this.mergeClaimedLabels(labels);
1503
- logger$14.info("Self-bootstrap: adopted ephemeral credentials", { agentId });
1586
+ logger$16.info("Self-bootstrap: adopted ephemeral credentials", { agentId });
1504
1587
  return true;
1505
1588
  }
1506
1589
  /**
@@ -1517,7 +1600,7 @@ var OrchestratorClient = class OrchestratorClient {
1517
1600
  * and close the socket. A one-shot provisioning run then exits visibly.
1518
1601
  */
1519
1602
  failClaimPermanently(reason) {
1520
- logger$14.error("Self-bootstrap claim exchange failed -- claim code is invalid or expired. NOT retrying.", { reason });
1603
+ logger$16.error("Self-bootstrap claim exchange failed -- claim code is invalid or expired. NOT retrying.", { reason });
1521
1604
  this.authFailed = true;
1522
1605
  this.intentionalDisconnect = true;
1523
1606
  if (this.ws) {
@@ -1631,7 +1714,7 @@ var OrchestratorClient = class OrchestratorClient {
1631
1714
  this.pendingUserArtifactRequests.set(record.messageId, record.pending);
1632
1715
  this.resendableCompletes.set(record.messageId, record);
1633
1716
  this.sendDirect(record.frame);
1634
- logger$14.info("Re-sent artifact upload-complete after reconnect", {
1717
+ logger$16.info("Re-sent artifact upload-complete after reconnect", {
1635
1718
  messageId: record.messageId,
1636
1719
  attempt: record.attempts
1637
1720
  });
@@ -1676,7 +1759,7 @@ var OrchestratorClient = class OrchestratorClient {
1676
1759
  * and hand the ack's lifetime flags to `onRegistered`.
1677
1760
  */
1678
1761
  handleRegisterAck(msg) {
1679
- logger$14.info("Registration acknowledged by orchestrator", {
1762
+ logger$16.info("Registration acknowledged by orchestrator", {
1680
1763
  agentId: msg.agentId,
1681
1764
  labels: msg.labels,
1682
1765
  scalerManaged: msg.scalerManaged,
@@ -1701,7 +1784,7 @@ var OrchestratorClient = class OrchestratorClient {
1701
1784
  try {
1702
1785
  raw = JSON.parse(data.toString());
1703
1786
  } catch {
1704
- logger$14.warn("Malformed JSON received from orchestrator");
1787
+ logger$16.warn("Malformed JSON received from orchestrator");
1705
1788
  return;
1706
1789
  }
1707
1790
  const rawMsg = raw;
@@ -1761,13 +1844,13 @@ var OrchestratorClient = class OrchestratorClient {
1761
1844
  switch (msg.type) {
1762
1845
  case "auth.success":
1763
1846
  if (this._state === "authenticating") {
1764
- logger$14.info("Authentication successful, sending agent.register", { connectionId: msg.connectionId });
1847
+ logger$16.info("Authentication successful, sending agent.register", { connectionId: msg.connectionId });
1765
1848
  this._state = "registering";
1766
1849
  this.sendAgentRegister();
1767
1850
  }
1768
1851
  break;
1769
1852
  case "auth.failure":
1770
- logger$14.error("Authentication FAILED -- token is invalid or expired. NOT retrying.", { reason: msg.reason });
1853
+ logger$16.error("Authentication FAILED -- token is invalid or expired. NOT retrying.", { reason: msg.reason });
1771
1854
  this.authFailed = true;
1772
1855
  this.intentionalDisconnect = true;
1773
1856
  if (this.ws) {
@@ -1780,14 +1863,14 @@ var OrchestratorClient = class OrchestratorClient {
1780
1863
  this.handleRegisterAck(msg);
1781
1864
  break;
1782
1865
  case "job.dispatch":
1783
- logger$14.info("Job dispatch received", {
1866
+ logger$16.info("Job dispatch received", {
1784
1867
  runId: msg.runId,
1785
1868
  jobId: msg.jobId
1786
1869
  });
1787
1870
  this.onJobDispatch(msg);
1788
1871
  break;
1789
1872
  case "job.cancel":
1790
- logger$14.info("Job cancel received", {
1873
+ logger$16.info("Job cancel received", {
1791
1874
  runId: msg.runId,
1792
1875
  jobId: msg.jobId,
1793
1876
  reason: msg.reason
@@ -1795,7 +1878,7 @@ var OrchestratorClient = class OrchestratorClient {
1795
1878
  this.onJobCancel(msg);
1796
1879
  break;
1797
1880
  case "job.concurrency.ack": {
1798
- logger$14.info("Concurrency ack received", {
1881
+ logger$16.info("Concurrency ack received", {
1799
1882
  requestId: msg.requestId,
1800
1883
  action: msg.action
1801
1884
  });
@@ -1810,7 +1893,7 @@ var OrchestratorClient = class OrchestratorClient {
1810
1893
  break;
1811
1894
  }
1812
1895
  case "step.approval-resolved": {
1813
- logger$14.info("Step approval resolved", {
1896
+ logger$16.info("Step approval resolved", {
1814
1897
  requestId: msg.requestId,
1815
1898
  runId: msg.runId,
1816
1899
  jobId: msg.jobId,
@@ -1830,16 +1913,18 @@ var OrchestratorClient = class OrchestratorClient {
1830
1913
  break;
1831
1914
  }
1832
1915
  case "fleet.logs.request":
1833
- logger$14.info("Fleet log collection requested", {
1916
+ logger$16.info("Fleet log collection requested", {
1834
1917
  requestId: msg.requestId,
1835
1918
  logWindowHours: msg.logWindowHours
1836
1919
  });
1837
1920
  this.streamFleetBundle(msg);
1921
+ break;
1922
+ case "rate.limit.warning": logger$16.warn("Orchestrator rate-limited this agent and dropped a frame", { retryAfterMs: msg.retryAfterMs });
1838
1923
  }
1839
1924
  return;
1840
1925
  }
1841
1926
  if (heartbeatSchema.safeParse(raw).success) return;
1842
- logger$14.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1927
+ logger$16.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1843
1928
  }
1844
1929
  flushBuffer() {
1845
1930
  const events = this.eventBuffer.flush();
@@ -1853,11 +1938,11 @@ var OrchestratorClient = class OrchestratorClient {
1853
1938
  }
1854
1939
  this.disconnectedAt = null;
1855
1940
  if (events.length > 0) {
1856
- logger$14.info("Flushing event buffer", { count: events.length });
1941
+ logger$16.info("Flushing event buffer", { count: events.length });
1857
1942
  for (const msg of events) this.sendDirect(msg);
1858
1943
  }
1859
1944
  if (logLines.length > 0) {
1860
- logger$14.info("Flushing log buffer", { count: logLines.length });
1945
+ logger$16.info("Flushing log buffer", { count: logLines.length });
1861
1946
  for (let i = 0; i < logLines.length; i += OrchestratorClient.LOG_BATCH_SIZE) {
1862
1947
  const batch = logLines.slice(i, i + OrchestratorClient.LOG_BATCH_SIZE);
1863
1948
  this.sendAgentLogMessage(batch);
@@ -1910,14 +1995,14 @@ var OrchestratorClient = class OrchestratorClient {
1910
1995
  */
1911
1996
  blockMmdsAccess() {
1912
1997
  if (process.getuid?.() !== 0) {
1913
- logger$14.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1998
+ logger$16.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1914
1999
  return;
1915
2000
  }
1916
2001
  try {
1917
2002
  execSync("iptables -A OUTPUT -d 169.254.169.254 -j DROP", { timeout: 5e3 });
1918
- logger$14.info("MMDS access blocked via iptables");
2003
+ logger$16.info("MMDS access blocked via iptables");
1919
2004
  } catch (err) {
1920
- logger$14.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
2005
+ logger$16.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
1921
2006
  }
1922
2007
  }
1923
2008
  /**
@@ -1931,7 +2016,7 @@ var OrchestratorClient = class OrchestratorClient {
1931
2016
  messageId: `config-ack-${agentId}-${Date.now()}`,
1932
2017
  agentId
1933
2018
  }));
1934
- logger$14.info("Config ACK sent to orchestrator", { agentId });
2019
+ logger$16.info("Config ACK sent to orchestrator", { agentId });
1935
2020
  }
1936
2021
  }
1937
2022
  startHeartbeat() {
@@ -1998,12 +2083,12 @@ var OrchestratorClient = class OrchestratorClient {
1998
2083
  scheduleReconnect() {
1999
2084
  this.cancelReconnect();
2000
2085
  if (this.authFailed) {
2001
- logger$14.error("Not reconnecting: authentication permanently failed");
2086
+ logger$16.error("Not reconnecting: authentication permanently failed");
2002
2087
  return;
2003
2088
  }
2004
2089
  const delay = this.getReconnectDelay();
2005
2090
  this.reconnectAttempts++;
2006
- logger$14.info("Scheduling reconnect", {
2091
+ logger$16.info("Scheduling reconnect", {
2007
2092
  attempt: this.reconnectAttempts,
2008
2093
  delayMs: Math.round(delay)
2009
2094
  });
@@ -2022,6 +2107,32 @@ var OrchestratorClient = class OrchestratorClient {
2022
2107
  //#endregion
2023
2108
  //#region src/execution/console-capture.ts
2024
2109
  /**
2110
+ * AsyncLocalStorage holding the active CaptureSink.
2111
+ *
2112
+ * When a sink is active, the patched console.* methods route formatted lines
2113
+ * to the sink. When no sink is active, the patched methods fall through to
2114
+ * the original console methods.
2115
+ *
2116
+ * Nested runCaptured() shadows the outer sink for the duration of the inner
2117
+ * scope; ALS restores the outer sink when the inner scope exits.
2118
+ */
2119
+ const consoleCapture = new AsyncLocalStorage();
2120
+ const METHODS = [
2121
+ "log",
2122
+ "error",
2123
+ "warn",
2124
+ "info",
2125
+ "debug"
2126
+ ];
2127
+ const originals = {
2128
+ log: console.log.bind(console),
2129
+ error: console.error.bind(console),
2130
+ warn: console.warn.bind(console),
2131
+ info: console.info.bind(console),
2132
+ debug: console.debug.bind(console)
2133
+ };
2134
+ let installed = false;
2135
+ /**
2025
2136
  * Install monkey-patches on console.log / error / warn / info / debug.
2026
2137
  *
2027
2138
  * Idempotent: subsequent calls are no-ops.
@@ -2046,56 +2157,18 @@ function installConsoleCapture() {
2046
2157
  for (const line of lines) if (line) sink.addLine(line);
2047
2158
  };
2048
2159
  }
2049
- /**
2050
- * Run `fn` with the given sink active. console.* calls inside `fn` and any
2051
- * async descendants route to the sink until the returned promise resolves.
2052
- *
2053
- * If `installConsoleCapture()` has not been called, the sink is still tracked
2054
- * in ALS but console.* calls are not intercepted.
2055
- */
2056
- function runCaptured(sink, fn) {
2057
- return new Promise((resolve, reject) => {
2058
- consoleCapture.run(sink, () => {
2059
- try {
2060
- Promise.resolve(fn()).then(resolve, reject);
2061
- } catch (err) {
2062
- reject(err);
2063
- }
2064
- });
2065
- });
2066
- }
2067
- var consoleCapture, METHODS, originals, installed;
2068
- var init_console_capture = __esmMin((() => {
2069
- consoleCapture = new AsyncLocalStorage();
2070
- METHODS = [
2071
- "log",
2072
- "error",
2073
- "warn",
2074
- "info",
2075
- "debug"
2076
- ];
2077
- originals = {
2078
- log: console.log.bind(console),
2079
- error: console.error.bind(console),
2080
- warn: console.warn.bind(console),
2081
- info: console.info.bind(console),
2082
- debug: console.debug.bind(console)
2083
- };
2084
- installed = false;
2085
- }));
2086
2160
  //#endregion
2087
2161
  //#region src/routes/health.ts
2088
- init_console_capture();
2089
2162
  function safe(name, fallback = "unknown") {
2090
2163
  switch (name) {
2091
- case "version": return "0.6.1";
2092
- case "buildCommit": return "d32193b6b";
2093
- case "sdkVersion": return "0.6.1";
2094
- case "sdkBundleHash": return "22faf0da45de7c243ce87f80fd33ee51b1df52809fde457b16bb821678f65eb3";
2095
- case "sharedVersion": return "0.6.1";
2096
- case "sharedBundleHash": return "a79be949815735b9e36eecc716f7eb07aa36c7bffba62e798e7e19f31a474eff";
2097
- case "engineVersion": return "0.6.1";
2098
- case "engineBundleHash": return "e49533b19f122155b3a78d95d105f97da259c2a45dee579578324b0702c91708";
2164
+ case "version": return "0.8.0";
2165
+ case "buildCommit": return "57712173f";
2166
+ case "sdkVersion": return "0.8.0";
2167
+ case "sdkBundleHash": return "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
2168
+ case "sharedVersion": return "0.8.0";
2169
+ case "sharedBundleHash": return "c1c70e41a1ec14fd7cf3a6047251e9aa11258a21e3694ea89a50e8bf14b99f9d";
2170
+ case "engineVersion": return "0.8.0";
2171
+ case "engineBundleHash": return "b26b2f23059c2ceb06a65feb2dcfe2d8c8be45ccf66622823ffedf742d2d7599";
2099
2172
  default: return fallback;
2100
2173
  }
2101
2174
  }
@@ -2380,7 +2453,7 @@ async function gcStaleAgentTmpDirs(base = kiciTmpBase()) {
2380
2453
  * spawn fails and the caller clears the orchestrator's reboot-pending flag and
2381
2454
  * surfaces the error — the deadline sweep is the backstop.
2382
2455
  */
2383
- const logger$13 = createLogger({ prefix: "reboot" });
2456
+ const logger$15 = createLogger({ prefix: "reboot" });
2384
2457
  /** The OS reboot primitive for a Node platform string. */
2385
2458
  function rebootCommandFor(platform) {
2386
2459
  switch (platform) {
@@ -2418,7 +2491,7 @@ function issueReboot(platform = process.platform) {
2418
2491
  stdio: "ignore"
2419
2492
  });
2420
2493
  child.on("error", (err) => {
2421
- logger$13.error("Reboot command failed to spawn", {
2494
+ logger$15.error("Reboot command failed to spawn", {
2422
2495
  cmd,
2423
2496
  args,
2424
2497
  error: String(err)
@@ -2426,14 +2499,14 @@ function issueReboot(platform = process.platform) {
2426
2499
  reject(err);
2427
2500
  });
2428
2501
  child.on("exit", (code) => {
2429
- if (code !== 0 && code !== null) logger$13.warn("Reboot command exited non-zero (privilege denied?)", {
2502
+ if (code !== 0 && code !== null) logger$15.warn("Reboot command exited non-zero (privilege denied?)", {
2430
2503
  cmd,
2431
2504
  args,
2432
2505
  code
2433
2506
  });
2434
2507
  });
2435
2508
  child.unref();
2436
- logger$13.info("Issued host reboot", {
2509
+ logger$15.info("Issued host reboot", {
2437
2510
  cmd,
2438
2511
  args
2439
2512
  });
@@ -3139,7 +3212,8 @@ async function startJobGitCredentials(args) {
3139
3212
  helperPath: host.helperPath,
3140
3213
  onGitGrantRequest,
3141
3214
  withRef: (params) => {
3142
- const { credential, ...rest } = params;
3215
+ const { credential, ref: incoming, ...rest } = params;
3216
+ if (incoming !== void 0) throw new Error("git credential refs are resolved from the job's lock by name; pass 'credential', not 'ref'");
3143
3217
  const ref = refFor(typeof credential === "string" ? credential : void 0);
3144
3218
  return ref ? {
3145
3219
  ...rest,
@@ -3159,573 +3233,133 @@ var init_job_git_credentials = __esmMin((() => {
3159
3233
  init_credential_helper_host();
3160
3234
  }));
3161
3235
  //#endregion
3162
- //#region src/checkout/changed-files.ts
3163
- /** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
3164
- async function buildAuthCtx(auth) {
3165
- if (!auth) return { args: [] };
3166
- if (auth.kind === "basic") {
3167
- const user = auth.user ?? "x-access-token";
3168
- return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
3169
- }
3170
- const sshSetup = await setupSshAuth({
3171
- privateKey: auth.secret,
3172
- hostKeyPolicy: auth.sshHostKeyPolicy,
3173
- knownHosts: auth.sshKnownHostsPem
3236
+ //#region src/execution/source-packer.ts
3237
+ /**
3238
+ * `.kici/` source tarball creation for build agents.
3239
+ *
3240
+ * After cloning a customer repo, packs the `.kici/` directory (excluding
3241
+ * `node_modules/` that lives in its own cached tarball per `dep-packer.ts`)
3242
+ * into a deterministic gzip tarball. The tarball bytes are hashed for
3243
+ * integrity verification on the execution-job side.
3244
+ *
3245
+ * The returned hash is the tarball's OWN digest. The orchestrator stores the
3246
+ * object under it and the restoring agent verifies the downloaded bytes against
3247
+ * it, the same contract the dependency tarball has always had; a separate
3248
+ * pointer resolves the workflow `contentHash` to it.
3249
+ */
3250
+ async function packKiciSource(workDir) {
3251
+ const kiciDir = join(workDir, ".kici");
3252
+ if (!existsSync(kiciDir)) throw new Error(`.kici/ not found at ${kiciDir}`);
3253
+ logger$14.info("Packing .kici/ source tarball", { dir: workDir });
3254
+ const startTime = Date.now();
3255
+ const stream = c({
3256
+ gzip: true,
3257
+ cwd: workDir,
3258
+ portable: true,
3259
+ filter: (filePath) => !filePath.startsWith(KICI_SOURCE_EXCLUDED_PREFIX)
3260
+ }, [".kici"]);
3261
+ const chunks = [];
3262
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
3263
+ const tarball = Buffer.concat(chunks);
3264
+ const hash = sha256(tarball);
3265
+ const sizeKB = (tarball.length / 1024).toFixed(2);
3266
+ const durationMs = Date.now() - startTime;
3267
+ logger$14.info(".kici/ source packed", {
3268
+ sizeKB,
3269
+ hash: hash.slice(0, 12),
3270
+ durationMs
3174
3271
  });
3175
3272
  return {
3176
- args: [],
3177
- env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
3178
- cleanup: () => sshSetup.cleanup()
3273
+ tarball,
3274
+ hash
3179
3275
  };
3180
3276
  }
3181
- function git(workDir, args, ctx) {
3182
- return execFileSync("git", [
3183
- ...ctx.args,
3184
- ...BASE_GIT_ARGS,
3185
- "-C",
3186
- workDir,
3187
- ...args
3188
- ], {
3189
- encoding: "utf8",
3190
- stdio: [
3191
- "ignore",
3192
- "pipe",
3193
- "pipe"
3194
- ],
3195
- ...ctx.env && { env: {
3196
- ...process.env,
3197
- ...ctx.env
3198
- } }
3199
- });
3200
- }
3201
- function tryGit(workDir, args, ctx) {
3202
- try {
3203
- git(workDir, args, ctx);
3204
- return true;
3205
- } catch {
3206
- return false;
3207
- }
3277
+ var logger$14;
3278
+ var init_source_packer = __esmMin((() => {
3279
+ logger$14 = createLogger({ prefix: "source-packer" });
3280
+ }));
3281
+ //#endregion
3282
+ //#region src/execution/dep-restore.ts
3283
+ /**
3284
+ * Dependency restoration from cached tarballs.
3285
+ *
3286
+ * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
3287
+ * and extracts to .kici/node_modules/ in the work directory.
3288
+ *
3289
+ * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
3290
+ * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
3291
+ * file:// URLs use a buffer-based approach (local, no streaming benefit).
3292
+ *
3293
+ * Streaming downloads have a 5-minute timeout and up to 2 retries.
3294
+ */
3295
+ /**
3296
+ * Compute SHA-256 hash of a buffer.
3297
+ */
3298
+ function computeHash(data) {
3299
+ return sha256(data);
3208
3300
  }
3209
- function parseNameOnly(out) {
3210
- return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
3301
+ /**
3302
+ * Extract a gzip tarball from a buffer into the target directory.
3303
+ * Used for file:// URLs where streaming provides no benefit.
3304
+ */
3305
+ async function extractTarball(data, targetDir) {
3306
+ await mkdir(targetDir, { recursive: true });
3307
+ const readable = Readable.from(data);
3308
+ await new Promise((resolve, reject) => {
3309
+ readable.pipe(x({
3310
+ cwd: targetDir,
3311
+ gzip: true
3312
+ })).on("finish", resolve).on("error", reject);
3313
+ });
3211
3314
  }
3212
- /** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
3213
- function ensureCommit(workDir, commitish, ctx) {
3214
- if (tryGit(workDir, [
3215
- "cat-file",
3216
- "-e",
3217
- `${commitish}^{commit}`
3218
- ], ctx)) return true;
3219
- if (tryGit(workDir, [
3220
- "fetch",
3221
- "--depth",
3222
- "1",
3223
- "origin",
3224
- commitish
3225
- ], ctx)) {
3226
- if (tryGit(workDir, [
3227
- "cat-file",
3228
- "-e",
3229
- `${commitish}^{commit}`
3230
- ], ctx)) return true;
3231
- }
3232
- for (let i = 0; i < MAX_DEEPEN; i++) {
3233
- if (!tryGit(workDir, [
3234
- "fetch",
3235
- `--deepen=${DEEPEN_STEP}`,
3236
- "origin"
3237
- ], ctx)) break;
3238
- if (tryGit(workDir, [
3239
- "cat-file",
3240
- "-e",
3241
- `${commitish}^{commit}`
3242
- ], ctx)) return true;
3243
- }
3244
- return false;
3315
+ /**
3316
+ * Stream download and extract an HTTP/HTTPS tarball.
3317
+ *
3318
+ * Computes SHA-256 hash on the fly via a Transform stream.
3319
+ * Returns the computed hash of the compressed tarball data.
3320
+ */
3321
+ async function streamFetchAndExtract(url, targetDir) {
3322
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
3323
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3324
+ if (!response.body) throw new Error("No response body");
3325
+ const nodeStream = Readable.fromWeb(response.body);
3326
+ const hash = createHash("sha256");
3327
+ const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
3328
+ hash.update(chunk);
3329
+ callback(null, chunk);
3330
+ } });
3331
+ await mkdir(targetDir, { recursive: true });
3332
+ await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
3333
+ return hash.digest("hex");
3245
3334
  }
3246
- function pushDiff(workDir, before, ctx) {
3247
- const isZero = !before || ZERO_SHA.test(before);
3248
- const baseRef = isZero ? EMPTY_TREE_SHA : before;
3249
- if (!isZero && !ensureCommit(workDir, before, ctx)) return {
3250
- files: [],
3251
- status: "unavailable"
3252
- };
3335
+ /**
3336
+ * Extract the dep tarball into a per-attempt scratch dir so retries never race
3337
+ * with still-draining I/O from a previous failed attempt.
3338
+ *
3339
+ * When `pipeline()` rejects on a network error or AbortSignal timeout, the
3340
+ * underlying `tar.x` continues flushing pending file writes for an unbounded
3341
+ * window after the promise settles — `pipeline` does not block on async
3342
+ * filesystem side effects. If the next retry then runs `rm -rf` on the same
3343
+ * `node_modules/`, the walk races with those writes and `rmdir` fails with
3344
+ * ENOTEMPTY (new files keep appearing under a directory we just emptied).
3345
+ *
3346
+ * We sidestep the race entirely by extracting each attempt into a unique
3347
+ * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
3348
+ * draining writes are harmless — the next attempt does not touch them. On
3349
+ * success `moveScratchIntoRepo` renames the extracted entries into place
3350
+ * (atomic on the same filesystem), then best-effort cleans the scratch dir.
3351
+ *
3352
+ * Scratch dirs land inside the customer's cloned working tree, so the clone
3353
+ * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
3354
+ * keep them out of `git status` for any workflow step that shells out to git.
3355
+ * See `excludeScratchFromGit`.
3356
+ */
3357
+ async function extractIntoScratch(url, kiciDir, attempt) {
3358
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
3359
+ await mkdir(scratchDir, { recursive: true });
3253
3360
  return {
3254
- files: parseNameOnly(git(workDir, [
3255
- "diff",
3256
- "--name-only",
3257
- baseRef,
3258
- "HEAD"
3259
- ], ctx)),
3260
- status: "fetched"
3261
- };
3262
- }
3263
- function prDiff(workDir, base, ctx) {
3264
- const candidates = [
3265
- base,
3266
- `origin/${base}`,
3267
- "FETCH_HEAD"
3268
- ];
3269
- const resolveBase = () => candidates.find((c) => tryGit(workDir, [
3270
- "rev-parse",
3271
- "--verify",
3272
- `${c}^{commit}`
3273
- ], ctx));
3274
- let baseRef = resolveBase();
3275
- if (!baseRef) {
3276
- if (!ensureCommit(workDir, base, ctx)) return {
3277
- files: [],
3278
- status: "unavailable"
3279
- };
3280
- baseRef = resolveBase();
3281
- }
3282
- if (!baseRef) return {
3283
- files: [],
3284
- status: "unavailable"
3285
- };
3286
- for (let i = 0; i <= MAX_DEEPEN; i++) {
3287
- if (tryGit(workDir, [
3288
- "merge-base",
3289
- baseRef,
3290
- "HEAD"
3291
- ], ctx)) return {
3292
- files: parseNameOnly(git(workDir, [
3293
- "diff",
3294
- "--name-only",
3295
- `${baseRef}...HEAD`
3296
- ], ctx)),
3297
- status: "fetched"
3298
- };
3299
- if (!tryGit(workDir, [
3300
- "fetch",
3301
- `--deepen=${DEEPEN_STEP}`,
3302
- "origin"
3303
- ], ctx)) break;
3304
- }
3305
- return {
3306
- files: [],
3307
- status: "unavailable"
3308
- };
3309
- }
3310
- /**
3311
- * Compute the changed-files list from the agent's local clone (HEAD is the
3312
- * checked-out head commit). Ground truth for job/step rule evaluation. `auth`
3313
- * (the same credentials used for the clone) authenticates the deepen / fetch
3314
- * calls so a private remote resolves. Returns `unavailable` for diff-less
3315
- * events (schedule/tag/manual) or any git failure — never throws.
3316
- */
3317
- async function computeChangedFiles(workDir, event, auth) {
3318
- let ctx;
3319
- try {
3320
- if (event.type !== "push" && event.type !== "pull_request") return {
3321
- files: [],
3322
- status: "unavailable"
3323
- };
3324
- ctx = await buildAuthCtx(auth);
3325
- if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
3326
- const base = event.baseBranch ?? event.targetBranch;
3327
- if (!base) return {
3328
- files: [],
3329
- status: "unavailable"
3330
- };
3331
- return prDiff(workDir, base, ctx);
3332
- } catch {
3333
- return {
3334
- files: [],
3335
- status: "unavailable"
3336
- };
3337
- } finally {
3338
- if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
3339
- }
3340
- }
3341
- var EMPTY_TREE_SHA, ZERO_SHA, MAX_DEEPEN, DEEPEN_STEP, BASE_GIT_ARGS;
3342
- var init_changed_files = __esmMin((() => {
3343
- init_ssh_auth();
3344
- EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
3345
- ZERO_SHA = /^0+$/;
3346
- MAX_DEEPEN = 4;
3347
- DEEPEN_STEP = 50;
3348
- BASE_GIT_ARGS = [
3349
- "-c",
3350
- "safe.directory=*",
3351
- "-c",
3352
- "core.quotePath=false"
3353
- ];
3354
- }));
3355
- //#endregion
3356
- //#region src/execution/generator-context.ts
3357
- /**
3358
- * Build the context handed to a `DynamicJobFn`.
3359
- *
3360
- * Optional members are spread conditionally rather than assigned `undefined`,
3361
- * so an absent `needs` / repo pair leaves no key behind — a present-but-
3362
- * undefined key reads as "declared" to a generator and serializes differently
3363
- * between the two evaluations.
3364
- */
3365
- function buildGeneratorContext(input) {
3366
- const { workflowName, event, env, repos, needs, $, log, kici } = input;
3367
- return {
3368
- $,
3369
- ctx: {
3370
- workflow: { name: workflowName },
3371
- event,
3372
- ...needs && { needs }
3373
- },
3374
- log,
3375
- env,
3376
- kici,
3377
- ...repos && {
3378
- sourceRepo: repos.sourceRepo,
3379
- workflowRepo: repos.workflowRepo
3380
- }
3381
- };
3382
- }
3383
- var init_generator_context = __esmMin((() => {}));
3384
- //#endregion
3385
- //#region src/execution/workflow-loader.ts
3386
- /**
3387
- * Workflow module loading: transforms `.ts` workflow files on import via the
3388
- * `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook. Customer
3389
- * workflow code is imported
3390
- * directly from the cloned / extracted source tree — no intermediate bundle,
3391
- * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3392
- * Node's normal ESM lookup against `.kici/node_modules/`.
3393
- */
3394
- var workflow_loader_exports = /* @__PURE__ */ __exportAll({
3395
- COMPILE_SCHEMA_VERSION: () => 5,
3396
- ensureLoaderHookRegistered: () => ensureLoaderHookRegistered,
3397
- extractDynamicJobFn: () => extractDynamicJobFn,
3398
- extractSteps: () => extractSteps,
3399
- extractStepsFromDynamicJob: () => extractStepsFromDynamicJob,
3400
- extractWorkflow: () => extractWorkflow,
3401
- loadWorkflowSource: () => loadWorkflowSource,
3402
- resolveWorkflowSdkSetters: () => resolveWorkflowSdkSetters
3403
- });
3404
- /**
3405
- * Resolve the `@kici-dev/sdk` instance the workflow module itself imports.
3406
- *
3407
- * The workflow's `.result` proxies read the module-global step-outputs map of
3408
- * whichever SDK copy the workflow file resolves — which is generally a
3409
- * different physical module than the agent's bundled SDK (the workflow is
3410
- * imported from the cloned source tree and resolves its deps against that
3411
- * tree's `node_modules`). Resolving via `createRequire(workflowFilePath)` walks
3412
- * `node_modules` from the workflow file exactly the way the workflow's own
3413
- * `import '@kici-dev/sdk'` does — including any hoisted copy — so the returned
3414
- * setters mutate the SAME module-global map object the proxies read. Node caches
3415
- * ESM modules by resolved URL, so importing that path yields the workflow's live
3416
- * singleton, not a fresh copy.
3417
- *
3418
- * Falls back to the agent's bundled setters when resolution fails (mirrors
3419
- * `resolveSdkSetters` in the compiler's test runner).
3420
- */
3421
- async function resolveWorkflowSdkSetters(workflowFilePath) {
3422
- try {
3423
- const sdkEntry = createRequire(workflowFilePath).resolve("@kici-dev/sdk");
3424
- const sdk = await import(pathToFileURL(sdkEntry).href);
3425
- if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
3426
- setStepOutputsMap: sdk.setStepOutputsMap,
3427
- setStepRefMap: sdk.setStepRefMap,
3428
- setJobOutputsMap: sdk.setJobOutputsMap
3429
- };
3430
- } catch {}
3431
- return {
3432
- setStepOutputsMap,
3433
- setStepRefMap,
3434
- setJobOutputsMap
3435
- };
3436
- }
3437
- function ensureLoaderHookRegistered() {
3438
- if (hookRegistered) return;
3439
- const hookPath = process.env.KICI_TS_LOADER_HOOK_PATH;
3440
- if (hookPath) register(pathToFileURL(hookPath).href, import.meta.url);
3441
- else register("@kici-dev/core/ts-loader-hook", import.meta.url);
3442
- hookRegistered = true;
3443
- }
3444
- /**
3445
- * Compute content hash for a workflow (same formula as `@kici-dev/compiler`
3446
- * lockfile/hasher.ts). Used to verify the loaded source matches the lock
3447
- * file's contentHash when `expectedContentHash` is provided.
3448
- *
3449
- * Line endings in `rawSource` (and inside `assetDigest`) are normalized to LF
3450
- * before hashing. This matches the compiler-side normalization in
3451
- * `@kici-dev/compiler` `lockfile/hasher.ts` so Windows agents — where Git's
3452
- * `core.autocrlf=true` system default checks out text files with CRLF — agree
3453
- * with lockfiles compiled on Linux (LF).
3454
- */
3455
- function computeContentHash(rawSource, assetDigest) {
3456
- let input = `5:${normalizeLineEndings(rawSource)}`;
3457
- if (assetDigest !== void 0 && assetDigest.length > 0) input += `\0${normalizeLineEndings(assetDigest)}`;
3458
- return sha256(input);
3459
- }
3460
- async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
3461
- const parts = [];
3462
- for (const rel of resolvedPaths) {
3463
- const abs = path.join(workDir, rel);
3464
- try {
3465
- const content = await fsPromises.readFile(abs, "utf-8");
3466
- parts.push(`${rel}\n${content}`);
3467
- } catch {
3468
- parts.push(`${rel}\n`);
3469
- }
3470
- }
3471
- return parts.join("");
3472
- }
3473
- /**
3474
- * Load a workflow module by dynamic-importing its source file.
3475
- *
3476
- * Registers the oxc-transform loader hook (idempotent), then dynamic-imports
3477
- * the `.ts` file. Transitive imports resolve against the workspace's
3478
- * `node_modules/` the same way any `tsx`-style runner would — so host-repo
3479
- * helpers and `@kici-dev/sdk` Just Work.
3480
- *
3481
- * When `expectedContentHash` is provided, verifies the raw source matches
3482
- * the hash in the lock file. Drift between source and lock file produces a
3483
- * descriptive error that surfaces the baked agent SDK fingerprint (useful
3484
- * when debugging "is the agent running a stale build?").
3485
- */
3486
- async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, resolvedHashFiles) {
3487
- ensureLoaderHookRegistered();
3488
- const filePath = path.join(workDir, sourceFile);
3489
- if (expectedContentHash) {
3490
- const rawSource = await fsPromises.readFile(filePath, "utf-8");
3491
- let assetDigest;
3492
- if (resolvedHashFiles?.length) assetDigest = await buildAssetDigestFromResolvedPaths(workDir, resolvedHashFiles);
3493
- const actualHash = computeContentHash(rawSource, assetDigest);
3494
- 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.`);
3495
- }
3496
- return {
3497
- module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
3498
- sdkSetters: await resolveWorkflowSdkSetters(filePath)
3499
- };
3500
- }
3501
- /**
3502
- * Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
3503
- */
3504
- function isWorkflow(value) {
3505
- return typeof value === "object" && value !== null && "_tag" in value && value._tag === "Workflow";
3506
- }
3507
- /**
3508
- * Extract a workflow by name from a module's exports.
3509
- *
3510
- * Searches:
3511
- * 1. Default export (single Workflow or array of Workflows)
3512
- * 2. Named exports
3513
- */
3514
- function extractWorkflow(module, workflowName) {
3515
- if (module.default) {
3516
- const defaultExport = module.default;
3517
- if (isWorkflow(defaultExport) && defaultExport.name === workflowName) return defaultExport;
3518
- if (Array.isArray(defaultExport)) {
3519
- const found = defaultExport.find((item) => isWorkflow(item) && item.name === workflowName);
3520
- if (found) return found;
3521
- }
3522
- }
3523
- for (const [, value] of Object.entries(module)) if (isWorkflow(value) && value.name === workflowName) return value;
3524
- throw new Error(`Workflow '${workflowName}' not found in module exports`);
3525
- }
3526
- /**
3527
- * Extract a dynamic job function from a workflow by index.
3528
- */
3529
- function extractDynamicJobFn(workflow, index) {
3530
- if (index < 0 || index >= workflow.jobs.length) throw new Error(`Job index ${index} out of bounds (workflow '${workflow.name}' has ${workflow.jobs.length} jobs)`);
3531
- const item = workflow.jobs[index];
3532
- if (!isDynamicJobFn(item)) throw new Error(`Job at index ${index} in workflow '${workflow.name}' is not a dynamic job fn`);
3533
- return item;
3534
- }
3535
- /**
3536
- * Extract steps from a static job within a workflow.
3537
- */
3538
- function extractSteps(workflow, jobName) {
3539
- for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item.steps;
3540
- throw new Error(`Static job '${jobName}' not found in workflow '${workflow.name}'`);
3541
- }
3542
- /**
3543
- * Extract steps from a job generated by a DynamicJobFn.
3544
- *
3545
- * Re-evaluates the DynamicJobFn to get the generated Job[] array, then finds
3546
- * the job by name and returns its steps. This is necessary because
3547
- * DynamicJobFn-generated jobs' step functions are closures that can only be
3548
- * obtained by calling the DynamicJobFn again.
3549
- *
3550
- * The function must be deterministic: given the same event context, it should
3551
- * return the same jobs with the same step functions. When `expectedJobNames`
3552
- * is provided, the re-evaluated output is compared against the original eval.
3553
- * A sibling mismatch logs a warning; a missing target job throws a clear
3554
- * determinism error.
3555
- */
3556
- async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds, repos) {
3557
- const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
3558
- const { $ } = await import("zx");
3559
- const { createLogger } = await import("@kici-dev/shared");
3560
- const { buildKiciApi, buildNeedsContext } = await import("@kici-dev/sdk");
3561
- const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
3562
- const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
3563
- const needs = upstreamSnapshot ? buildNeedsContext(upstreamSnapshot, declaredNeeds ?? []) : void 0;
3564
- const generatedJobs = await dynamicFn(buildGeneratorContext({
3565
- workflowName: workflow.name,
3566
- event,
3567
- env,
3568
- ...repos && { repos },
3569
- ...needs && { needs },
3570
- $,
3571
- log,
3572
- kici
3573
- }));
3574
- const actualNames = generatedJobs.map((j) => j.name);
3575
- let droppedJobs = [];
3576
- if (expectedJobNames) {
3577
- const expectedSet = new Set(expectedJobNames);
3578
- const actualSet = new Set(actualNames);
3579
- const missing = expectedJobNames.filter((n) => !actualSet.has(n));
3580
- const extra = actualNames.filter((n) => !expectedSet.has(n));
3581
- droppedJobs = missing.filter((n) => n !== jobName);
3582
- if (missing.length > 0 || extra.length > 0) {
3583
- const detail = (missing.length > 0 ? `missing: [${missing.join(", ")}]` : "") + (missing.length > 0 && extra.length > 0 ? "; " : "") + (extra.length > 0 ? `unexpected: [${extra.join(", ")}]` : "");
3584
- 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.`);
3585
- 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.`);
3586
- }
3587
- }
3588
- for (const genJob of generatedJobs) if (genJob.name === jobName) return {
3589
- steps: genJob.steps,
3590
- droppedJobs
3591
- };
3592
- throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
3593
- }
3594
- var AGENT_SDK_VERSION, AGENT_SDK_BUNDLE_HASH, hookRegistered;
3595
- var init_workflow_loader = __esmMin((() => {
3596
- init_generator_context();
3597
- AGENT_SDK_VERSION = "0.6.1";
3598
- AGENT_SDK_BUNDLE_HASH = "22faf0da45de7c243ce87f80fd33ee51b1df52809fde457b16bb821678f65eb3";
3599
- hookRegistered = false;
3600
- }));
3601
- //#endregion
3602
- //#region src/execution/source-packer.ts
3603
- /**
3604
- * `.kici/` source tarball creation for build agents.
3605
- *
3606
- * After cloning a customer repo, packs the `.kici/` directory (excluding
3607
- * `node_modules/` — that lives in its own cached tarball per `dep-packer.ts`)
3608
- * into a deterministic gzip tarball. The tarball bytes are hashed for
3609
- * integrity verification on the execution-job side.
3610
- *
3611
- * The artifact is content-addressed by the compiler's `contentHash` (hash of
3612
- * the raw workflow entry + asset digest), not by the tarball bytes — so the
3613
- * orchestrator's cache lookup keys unchanged while the stored bytes switch
3614
- * from a Rolldown bundle to a raw-source tarball.
3615
- */
3616
- async function packKiciSource(workDir) {
3617
- const kiciDir = join(workDir, ".kici");
3618
- if (!existsSync(kiciDir)) throw new Error(`.kici/ not found at ${kiciDir}`);
3619
- logger$12.info("Packing .kici/ source tarball", { dir: workDir });
3620
- const startTime = Date.now();
3621
- const stream = c({
3622
- gzip: true,
3623
- cwd: workDir,
3624
- portable: true,
3625
- filter: (filePath) => !filePath.startsWith(".kici/node_modules")
3626
- }, [".kici"]);
3627
- const chunks = [];
3628
- for await (const chunk of stream) chunks.push(Buffer.from(chunk));
3629
- const tarball = Buffer.concat(chunks);
3630
- const hash = sha256(tarball);
3631
- const sizeKB = (tarball.length / 1024).toFixed(2);
3632
- const durationMs = Date.now() - startTime;
3633
- logger$12.info(".kici/ source packed", {
3634
- sizeKB,
3635
- hash: hash.slice(0, 12),
3636
- durationMs
3637
- });
3638
- return {
3639
- tarball,
3640
- hash
3641
- };
3642
- }
3643
- var logger$12;
3644
- var init_source_packer = __esmMin((() => {
3645
- logger$12 = createLogger({ prefix: "source-packer" });
3646
- }));
3647
- //#endregion
3648
- //#region src/execution/dep-restore.ts
3649
- /**
3650
- * Dependency restoration from cached tarballs.
3651
- *
3652
- * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
3653
- * and extracts to .kici/node_modules/ in the work directory.
3654
- *
3655
- * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
3656
- * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
3657
- * file:// URLs use a buffer-based approach (local, no streaming benefit).
3658
- *
3659
- * Streaming downloads have a 5-minute timeout and up to 2 retries.
3660
- */
3661
- /**
3662
- * Compute SHA-256 hash of a buffer.
3663
- */
3664
- function computeHash(data) {
3665
- return sha256(data);
3666
- }
3667
- /**
3668
- * Extract a gzip tarball from a buffer into the target directory.
3669
- * Used for file:// URLs where streaming provides no benefit.
3670
- */
3671
- async function extractTarball(data, targetDir) {
3672
- await mkdir(targetDir, { recursive: true });
3673
- const readable = Readable.from(data);
3674
- await new Promise((resolve, reject) => {
3675
- readable.pipe(x({
3676
- cwd: targetDir,
3677
- gzip: true
3678
- })).on("finish", resolve).on("error", reject);
3679
- });
3680
- }
3681
- /**
3682
- * Stream download and extract an HTTP/HTTPS tarball.
3683
- *
3684
- * Computes SHA-256 hash on the fly via a Transform stream.
3685
- * Returns the computed hash of the compressed tarball data.
3686
- */
3687
- async function streamFetchAndExtract(url, targetDir) {
3688
- const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
3689
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3690
- if (!response.body) throw new Error("No response body");
3691
- const nodeStream = Readable.fromWeb(response.body);
3692
- const hash = createHash("sha256");
3693
- const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
3694
- hash.update(chunk);
3695
- callback(null, chunk);
3696
- } });
3697
- await mkdir(targetDir, { recursive: true });
3698
- await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
3699
- return hash.digest("hex");
3700
- }
3701
- /**
3702
- * Extract the dep tarball into a per-attempt scratch dir so retries never race
3703
- * with still-draining I/O from a previous failed attempt.
3704
- *
3705
- * When `pipeline()` rejects on a network error or AbortSignal timeout, the
3706
- * underlying `tar.x` continues flushing pending file writes for an unbounded
3707
- * window after the promise settles — `pipeline` does not block on async
3708
- * filesystem side effects. If the next retry then runs `rm -rf` on the same
3709
- * `node_modules/`, the walk races with those writes and `rmdir` fails with
3710
- * ENOTEMPTY (new files keep appearing under a directory we just emptied).
3711
- *
3712
- * We sidestep the race entirely by extracting each attempt into a unique
3713
- * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
3714
- * draining writes are harmless — the next attempt does not touch them. On
3715
- * success `moveScratchIntoRepo` renames the extracted entries into place
3716
- * (atomic on the same filesystem), then best-effort cleans the scratch dir.
3717
- *
3718
- * Scratch dirs land inside the customer's cloned working tree, so the clone
3719
- * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
3720
- * keep them out of `git status` for any workflow step that shells out to git.
3721
- * See `excludeScratchFromGit`.
3722
- */
3723
- async function extractIntoScratch(url, kiciDir, attempt) {
3724
- const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
3725
- await mkdir(scratchDir, { recursive: true });
3726
- return {
3727
- scratchDir,
3728
- hash: await streamFetchAndExtract(url, scratchDir)
3361
+ scratchDir,
3362
+ hash: await streamFetchAndExtract(url, scratchDir)
3729
3363
  };
3730
3364
  }
3731
3365
  /**
@@ -3768,7 +3402,7 @@ async function excludeScratchFromGit(repoWorkDir) {
3768
3402
  const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
3769
3403
  await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
3770
3404
  } catch (err) {
3771
- logger$11.warn("Failed to register scratch dir glob in .git/info/exclude", {
3405
+ logger$13.warn("Failed to register scratch dir glob in .git/info/exclude", {
3772
3406
  excludePath,
3773
3407
  error: err instanceof Error ? err.message : String(err)
3774
3408
  });
@@ -3829,7 +3463,7 @@ async function cleanupScratch(scratchDir) {
3829
3463
  force: true
3830
3464
  });
3831
3465
  } catch (cleanupErr) {
3832
- logger$11.warn("Scratch dir cleanup failed (orphan left behind)", {
3466
+ logger$13.warn("Scratch dir cleanup failed (orphan left behind)", {
3833
3467
  scratchDir,
3834
3468
  error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
3835
3469
  });
@@ -3854,7 +3488,7 @@ async function cleanupScratch(scratchDir) {
3854
3488
  */
3855
3489
  async function restoreDeps(workDir, depsUrl, depsHash) {
3856
3490
  depsUrl = resolveOrchestratorUrl(depsUrl);
3857
- logger$11.info("Downloading dependency tarball", { url: depsUrl });
3491
+ logger$13.info("Downloading dependency tarball", { url: depsUrl });
3858
3492
  const kiciDir = join(workDir, ".kici");
3859
3493
  if (depsUrl.startsWith("file://")) {
3860
3494
  const localPath = fileURLToPath(depsUrl);
@@ -3868,7 +3502,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3868
3502
  await moveScratchIntoRepo(scratchDir, workDir);
3869
3503
  await cleanupScratch(scratchDir);
3870
3504
  const sizeMB = (data.length / 1048576).toFixed(2);
3871
- logger$11.info("Dependencies restored from cache (file)", {
3505
+ logger$13.info("Dependencies restored from cache (file)", {
3872
3506
  sizeMB,
3873
3507
  targetDir: workDir
3874
3508
  });
@@ -3877,7 +3511,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3877
3511
  if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
3878
3512
  let lastError;
3879
3513
  for (let attempt = 0; attempt <= 2; attempt++) {
3880
- if (attempt > 0) logger$11.warn("Retrying dep tarball download", {
3514
+ if (attempt > 0) logger$13.warn("Retrying dep tarball download", {
3881
3515
  attempt,
3882
3516
  url: depsUrl
3883
3517
  });
@@ -3886,11 +3520,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3886
3520
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
3887
3521
  await moveScratchIntoRepo(scratchDir, workDir);
3888
3522
  await cleanupScratch(scratchDir);
3889
- logger$11.info("Dependencies restored from cache (stream)", { targetDir: workDir });
3523
+ logger$13.info("Dependencies restored from cache (stream)", { targetDir: workDir });
3890
3524
  return;
3891
3525
  } catch (err) {
3892
3526
  lastError = err instanceof Error ? err : new Error(String(err));
3893
- logger$11.warn("Dep tarball download failed", {
3527
+ logger$13.warn("Dep tarball download failed", {
3894
3528
  attempt,
3895
3529
  error: lastError.message
3896
3530
  });
@@ -3898,9 +3532,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3898
3532
  }
3899
3533
  throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
3900
3534
  }
3901
- var logger$11, DOWNLOAD_TIMEOUT_MS$1, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
3535
+ var logger$13, DOWNLOAD_TIMEOUT_MS$1, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
3902
3536
  var init_dep_restore = __esmMin((() => {
3903
- logger$11 = createLogger({ prefix: "dep-restore" });
3537
+ logger$13 = createLogger({ prefix: "dep-restore" });
3904
3538
  DOWNLOAD_TIMEOUT_MS$1 = 3e5;
3905
3539
  SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
3906
3540
  SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
@@ -4013,7 +3647,7 @@ async function uploadToPresignedUrl(url, data, opts) {
4013
3647
  for (let attempt = 0; attempt <= 2; attempt++) {
4014
3648
  if (attempt > 0) {
4015
3649
  const delayMs = baseDelayMs * 2 ** (attempt - 1);
4016
- logger$10.warn("Retrying pre-signed upload", {
3650
+ logger$12.warn("Retrying pre-signed upload", {
4017
3651
  attempt,
4018
3652
  delayMs,
4019
3653
  error: lastError?.message
@@ -4030,10 +3664,10 @@ async function uploadToPresignedUrl(url, data, opts) {
4030
3664
  }
4031
3665
  throw new Error(`Pre-signed upload failed after 3 attempts: ${lastError?.message}`);
4032
3666
  }
4033
- var logger$10, DOWNLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
3667
+ var logger$12, DOWNLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
4034
3668
  var init_download = __esmMin((() => {
4035
3669
  init_dep_restore();
4036
- logger$10 = createLogger({ prefix: "agent:download" });
3670
+ logger$12 = createLogger({ prefix: "agent:download" });
4037
3671
  DOWNLOAD_TIMEOUT_MS = 3e5;
4038
3672
  UPLOAD_TIMEOUT_MS = 3e5;
4039
3673
  UPLOAD_RETRY_BASE_DELAY_MS = 500;
@@ -4052,22 +3686,30 @@ var init_download = __esmMin((() => {
4052
3686
  * `.kici/` source tarball restoration for execution agents.
4053
3687
  *
4054
3688
  * Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
4055
- * and extracts it into `workDir/` so the workflow entry point becomes
3689
+ * and installs it at `workDir/.kici` so the workflow entry point becomes
4056
3690
  * importable. Mirrors the shape of `dep-restore.ts` but without the streaming
4057
3691
  * optimization — source tarballs are tiny (kilobytes, not the hundreds of
4058
3692
  * megabytes a `node_modules/` tarball carries).
4059
3693
  *
4060
- * Note on integrity: `dispatch.sourceTarHash` is the workflow `contentHash`
4061
- * (computed over the raw source per `workflow-loader.ts::computeContentHash`),
4062
- * not the SHA-256 of the tarball bytes. The shared S3 cache key is derived
4063
- * from that same contentHash, so a signed GET URL from the orchestrator
4064
- * already establishes provenance for restored tarballs. Every
4065
- * `loadWorkflowSource` call site build, init, and dynamic eval — passes
4066
- * the dispatched `contentHash` (and `resolvedHashFiles` when present) so
4067
- * the lock-vs-source drift gate fires at each author-TS load site, not
4068
- * only the build phase. That closes the corner cases where init or eval
4069
- * runs without a preceding build (cache infrastructure unavailable, or a
4070
- * build job that failed but left dynamic dispatch in flight).
3694
+ * Two properties this path is responsible for, both of which it previously
3695
+ * lacked:
3696
+ *
3697
+ * **Verification.** `dispatch.sourceTarDigest` is the SHA-256 of the tarball's
3698
+ * own bytes, so the download is checked before anything is extracted — the same
3699
+ * contract `restoreDeps` has always had via `depsHash`. The older
3700
+ * `dispatch.sourceTarHash` field carries the workflow `contentHash` instead, so
3701
+ * it never could serve this purpose; it stays on the wire for older peers and
3702
+ * is deliberately not used as a verification input here. When no digest is
3703
+ * dispatched (an older orchestrator, or a source that did not come from the
3704
+ * content-addressed cache) the restore proceeds unverified rather than failing,
3705
+ * so a mixed-version rollout still runs.
3706
+ *
3707
+ * **Replacement, not overlay.** Extraction lands in a scratch directory and the
3708
+ * result REPLACES `workDir/.kici` wholesale, save for `node_modules/` — the one
3709
+ * directory the tarball deliberately omits, which the deps restore has already
3710
+ * written by the time this runs. Extracting over the existing tree left any file
3711
+ * the tarball no longer carries in place, so a helper the author deleted
3712
+ * survived every warm-cache run and kept being imported.
4071
3713
  */
4072
3714
  async function extractSourceTarball(data, targetDir) {
4073
3715
  await mkdir(targetDir, { recursive: true });
@@ -4079,9 +3721,16 @@ async function extractSourceTarball(data, targetDir) {
4079
3721
  })).on("finish", resolve).on("error", reject);
4080
3722
  });
4081
3723
  }
4082
- async function restoreSource(workDir, sourceTarUrl) {
3724
+ /**
3725
+ * Download, verify, and install the `.kici/` source tree.
3726
+ *
3727
+ * @param workDir - Root of the cloned repository; `.kici` is replaced under it
3728
+ * @param sourceTarUrl - `http://`, `https://`, or `file://` URL to the tarball
3729
+ * @param sourceTarDigest - Expected SHA-256 of the tarball bytes, when known
3730
+ */
3731
+ async function restoreSource(workDir, sourceTarUrl, sourceTarDigest) {
4083
3732
  sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
4084
- logger$9.info("Restoring .kici/ source from tarball", { sourceTarUrl });
3733
+ logger$11.info("Restoring .kici/ source from tarball", { sourceTarUrl });
4085
3734
  const startTime = Date.now();
4086
3735
  let data;
4087
3736
  if (sourceTarUrl.startsWith("file://")) {
@@ -4089,174 +3738,41 @@ async function restoreSource(workDir, sourceTarUrl) {
4089
3738
  data = await fsPromises.readFile(localPath);
4090
3739
  } else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
4091
3740
  else throw new Error(`Unsupported source tarball URL scheme: ${sourceTarUrl}`);
4092
- await extractSourceTarball(data, workDir);
4093
- const durationMs = Date.now() - startTime;
4094
- logger$9.info(".kici/ source restored", {
3741
+ if (sourceTarDigest) {
3742
+ const actual = createHash("sha256").update(data).digest("hex");
3743
+ if (actual !== sourceTarDigest) throw new Error(`Source tarball hash mismatch: expected ${sourceTarDigest}, got ${actual}. The restored source does not match what the orchestrator dispatched.`);
3744
+ }
3745
+ const kiciDir = path.join(workDir, ".kici");
3746
+ const scratch = path.join(workDir, `.kici.restore-${process.pid}-${Date.now()}`);
3747
+ try {
3748
+ await extractSourceTarball(data, scratch);
3749
+ const extracted = path.join(scratch, ".kici");
3750
+ const src = (await fsPromises.stat(extracted).catch(() => null))?.isDirectory() ? extracted : scratch;
3751
+ const installedDeps = path.join(kiciDir, "node_modules");
3752
+ if (await fsPromises.stat(installedDeps).catch(() => null)) await rename(installedDeps, path.join(src, "node_modules"));
3753
+ await rm(kiciDir, {
3754
+ recursive: true,
3755
+ force: true
3756
+ });
3757
+ await rename(src, kiciDir);
3758
+ } finally {
3759
+ await rm(scratch, {
3760
+ recursive: true,
3761
+ force: true
3762
+ }).catch(() => {});
3763
+ }
3764
+ const durationMs = Date.now() - startTime;
3765
+ logger$11.info(".kici/ source restored", {
4095
3766
  sizeKB: (data.length / 1024).toFixed(2),
4096
- durationMs
3767
+ durationMs,
3768
+ verified: sourceTarDigest !== void 0
4097
3769
  });
4098
3770
  }
4099
- var logger$9;
3771
+ var logger$11;
4100
3772
  var init_source_restore = __esmMin((() => {
4101
3773
  init_download();
4102
3774
  init_dep_restore();
4103
- logger$9 = createLogger({ prefix: "source-restore" });
4104
- }));
4105
- //#endregion
4106
- //#region src/execution/timeout-util.ts
4107
- /**
4108
- * Shared timeout utility for wrapping async operations with a deadline.
4109
- *
4110
- * Used by init-runner (dynamic field evaluation) and dynamic job function evaluation.
4111
- */
4112
- /**
4113
- * Execute a function with a timeout using Promise.race.
4114
- * Throws if the function does not resolve within the given timeout.
4115
- */
4116
- async function withTimeout(fn, timeoutMs, label) {
4117
- const ac = new AbortController();
4118
- const timer = setTimeout(() => ac.abort(), timeoutMs);
4119
- try {
4120
- return await Promise.race([Promise.resolve(fn()), new Promise((_, reject) => {
4121
- ac.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms evaluating ${label}`)));
4122
- })]);
4123
- } finally {
4124
- clearTimeout(timer);
4125
- }
4126
- }
4127
- var init_timeout_util = __esmMin((() => {}));
4128
- //#endregion
4129
- //#region src/execution/init-runner.ts
4130
- /**
4131
- * Run a workflow's `filter` and report whether the workflow applies.
4132
- *
4133
- * Shared by both agent-side evaluation sites for a same-repo workflow: the init
4134
- * job that gates each static job's dispatch, and the dynamic-eval job that gates
4135
- * whether a generator runs at all. Both must reach the same verdict from the same
4136
- * inputs, so neither builds the context itself.
4137
- *
4138
- * The context is built through `createFilterContext` rather than as an object
4139
- * literal: the factory installs `changedFiles` as a throwing getter, so a filter
4140
- * that reads the diff on an event that has none fails loudly instead of seeing an
4141
- * empty list. A `false` verdict dispatches none of the workflow's own jobs, so a
4142
- * silently-empty diff would suppress it on a mistake. On this same-repo path the
4143
- * verdict is at least recoverable — the run row exists, carrying the `__init__*`
4144
- * jobs, and this evaluation's own step log records the verdict; it is the
4145
- * organization-wide path, which runs elsewhere, that leaves nothing behind.
4146
- *
4147
- * A throwing filter propagates: the evaluating job fails, which surfaces as a
4148
- * failed run. "Could not decide" is never treated as "do not run" — that would
4149
- * be a false green, the same reasoning `buildJobRuleCompletion` applies to a rule
4150
- * whose `check()` threw.
4151
- */
4152
- async function evaluateWorkflowFilter(workflow, event, input, timeoutMs) {
4153
- if (typeof workflow.filter !== "function") throw new Error(`Workflow '${workflow.name}' is recorded as declaring a filter, but its module exports none — the lock file is out of date. Run 'kici compile' and commit the result.`);
4154
- if (!input) throw new Error(`Workflow '${workflow.name}' declares a filter but the evaluating job supplied no filter context (source tree / changed files) to evaluate it against.`);
4155
- const filterFn = workflow.filter;
4156
- const ctx = createFilterContext({
4157
- sourceRepo: input.sourceRepo,
4158
- workflowRepo: input.workflowRepo,
4159
- event,
4160
- changedFiles: input.changedFiles,
4161
- changedFilesStatus: input.changedFilesStatus,
4162
- ...input.env && { env: input.env },
4163
- ...input.$ && { $: input.$ }
4164
- });
4165
- const verdict = await withTimeout(() => filterFn(ctx), timeoutMs, `filter for workflow '${workflow.name}'`);
4166
- return Boolean(verdict);
4167
- }
4168
- /**
4169
- * Find a static job by name in a workflow's jobs array.
4170
- * Skips dynamic job functions (factories).
4171
- */
4172
- function findJobByName(workflow, jobName) {
4173
- for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item;
4174
- throw new Error(`Job '${jobName}' not found in workflow '${workflow.name}'`);
4175
- }
4176
- /**
4177
- * Evaluate dynamic fields (context, env, concurrencyGroup) on a job.
4178
- *
4179
- * Only fields with their corresponding flag set to true AND whose property
4180
- * on the job is a function will be evaluated. All evaluations happen in a
4181
- * single call per.
4182
- *
4183
- * -: If a dynamic function throws, the error propagates (job fails).
4184
- * -: If a dynamic function returns undefined/null, the field is left undefined.
4185
- * -: Each dynamic function call is wrapped in a timeout (default 60s).
4186
- *
4187
- * A workflow-level `filter` is evaluated FIRST when `flags.hasFilter` is set. A
4188
- * `false` verdict returns immediately: no job of that workflow will be
4189
- * dispatched, so evaluating this one's dynamic fields would run customer code
4190
- * whose result nothing can consume.
4191
- *
4192
- * @param workflow - The extracted Workflow object
4193
- * @param jobName - Name of the job whose dynamic fields to evaluate
4194
- * @param event - Normalized event envelope — same shape every dynamic-function call site receives.
4195
- * @param flags - Which fields are dynamic and need evaluation
4196
- * @param timeoutMs - Timeout per dynamic function call (default 60_000ms)
4197
- * @param filterInput - Source tree + diff the workflow's `filter` reads. Required when `flags.hasFilter`.
4198
- */
4199
- async function evaluateDynamicFields(workflow, jobName, event, flags, timeoutMs = 6e4, filterInput) {
4200
- const result = {};
4201
- if (flags.hasFilter) {
4202
- result.filterPassed = await evaluateWorkflowFilter(workflow, event, filterInput, timeoutMs);
4203
- if (!result.filterPassed) return result;
4204
- }
4205
- const job = findJobByName(workflow, jobName);
4206
- if (flags.dynamicMatrix && typeof job.matrix === "function") {
4207
- const matrixContext = {
4208
- $: (await import("zx")).$,
4209
- ctx: {
4210
- workflow: { name: workflow.name },
4211
- job: {
4212
- name: jobName,
4213
- runsOn: job.runsOn
4214
- }
4215
- },
4216
- log: {
4217
- info: () => {},
4218
- warn: () => {},
4219
- error: () => {},
4220
- debug: () => {}
4221
- },
4222
- env: { ...process.env }
4223
- };
4224
- const resolved = await withTimeout(() => job.matrix(matrixContext), timeoutMs, `dynamicMatrix for job '${jobName}'`);
4225
- let combos;
4226
- try {
4227
- const rawCount = matrixCombinationCount(resolved);
4228
- if (rawCount > MAX_MATRIX_MATERIALIZATION) throw new MatrixShapeError(`matrix is too large to expand: ${rawCount} raw combinations (max ${MAX_MATRIX_MATERIALIZATION})`);
4229
- combos = expandMatrix(resolved);
4230
- } catch (err) {
4231
- if (err instanceof MatrixShapeError) throw new MatrixShapeError(`dynamicMatrix for job '${jobName}': ${err.message}`);
4232
- throw err;
4233
- }
4234
- if (job.include || job.exclude) combos = applyIncludeExclude(combos, job.include, job.exclude);
4235
- result.matrixValues = combos;
4236
- }
4237
- if (flags.dynamicContext) {
4238
- const envRefs = job.contexts ?? (job.context !== void 0 ? [job.context] : void 0);
4239
- if (envRefs && envRefs.length > 0) {
4240
- const names = [];
4241
- for (const ref of envRefs) if (typeof ref === "function") {
4242
- const value = await withTimeout(() => ref(event), timeoutMs, `dynamicContext for job '${jobName}'`);
4243
- if (value !== void 0 && value !== null) names.push(value);
4244
- } else if (typeof ref === "string") names.push(ref);
4245
- if (names.length > 0) result.contextNames = names;
4246
- }
4247
- }
4248
- if (flags.dynamicEnv && typeof job.env === "function") {
4249
- const value = await withTimeout(() => job.env(event), timeoutMs, `dynamicEnv for job '${jobName}'`);
4250
- if (value !== void 0 && value !== null) result.env = value;
4251
- }
4252
- if (flags.dynamicConcurrencyGroup && typeof job.concurrencyGroup === "function") {
4253
- const value = await withTimeout(() => job.concurrencyGroup(event), timeoutMs, `dynamicConcurrencyGroup for job '${jobName}'`);
4254
- if (value !== void 0 && value !== null) result.concurrencyGroup = value;
4255
- }
4256
- return result;
4257
- }
4258
- var init_init_runner = __esmMin((() => {
4259
- init_timeout_util();
3775
+ logger$11 = createLogger({ prefix: "source-restore" });
4260
3776
  }));
4261
3777
  //#endregion
4262
3778
  //#region src/bootstrap/ssh-exec.ts
@@ -5029,8 +4545,396 @@ var init_s3_payload_source = __esmMin((() => {
5029
4545
  };
5030
4546
  }
5031
4547
  };
4548
+ })), init_timeout_util = __esmMin((() => {})), MatrixExpansionError;
4549
+ var init_dynamic_job_serializer = __esmMin((() => {
4550
+ init_timeout_util();
4551
+ MatrixExpansionError = class MatrixExpansionError extends Error {
4552
+ jobName;
4553
+ name = "MatrixExpansionError";
4554
+ constructor(jobName, message) {
4555
+ super(message);
4556
+ this.jobName = jobName;
4557
+ Object.setPrototypeOf(this, MatrixExpansionError.prototype);
4558
+ }
4559
+ };
4560
+ }));
4561
+ //#endregion
4562
+ //#region src/execution/sandbox/env-sanitizer.ts
4563
+ /**
4564
+ * Environment sanitization for sandbox execution.
4565
+ *
4566
+ * Uses an explicit allowlist approach: only known-safe system variables are
4567
+ * copied from the host process.env. Agent-internal credentials (KICI_*,
4568
+ * DATABASE_URL, PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are NEVER included.
4569
+ *
4570
+ * 7-layer merge precedence (later overrides earlier):
4571
+ * 1. Allowed system vars from process.env
4572
+ * 2. Sandbox defaults (FORCE_COLOR=1, etc.)
4573
+ * 3. KICI_* system vars (orchestrator-generated, from userEnv)
4574
+ * 4. Org-level environment vars (from orchestrator via contextVars)
4575
+ * 5. Source-level environment overrides (merged into contextVars by orchestrator)
4576
+ * 6. Job env (from lock file env field, evaluated by orchestrator)
4577
+ * 7. setEnv() calls (runtime -- applied at step execution, not here)
4578
+ */
4579
+ /**
4580
+ * Build a sanitized environment for sandbox execution.
4581
+ *
4582
+ * Constructs an environment from scratch using the 7-layer merge:
4583
+ * 1. Explicitly allowed system variables from process.env
4584
+ * 2. Sandbox default variables (FORCE_COLOR, etc.)
4585
+ * 3. User-defined env vars (KICI_* system vars from orchestrator)
4586
+ * 4-5. Environment vars (org-level + source overrides, pre-merged by orchestrator)
4587
+ * 6. Job env (SDK-defined, evaluated by orchestrator)
4588
+ * 7. setEnv() calls (runtime -- not handled here)
4589
+ *
4590
+ * Secrets are NOT injected into environment variables by this function.
4591
+ * They flow through IPC to ctx.secrets and are only exposed via ctx.secrets.expose().
4592
+ *
4593
+ * Agent-internal credentials (KICI_ORCHESTRATOR_URL, DATABASE_URL,
4594
+ * PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are never included because they
4595
+ * are not in the allowlist.
4596
+ *
4597
+ * The `trustedEnv` option (default false) selects the trusted fleet-agent
4598
+ * profile: Layer 1 passes the ambient host env through instead of restricting
4599
+ * to `ALLOWED_SYSTEM_VARS`, minus the agent's own KiCI identity/operational
4600
+ * secrets (the whole `KICI_*` namespace + a small non-`KICI_` infra denylist).
4601
+ * It is read ONLY from agent config (`KICI_TRUSTED_ENV`), never from a dispatch
4602
+ * payload, so a workflow cannot request it. With it OFF the output is
4603
+ * byte-identical to the allowlist-only behavior.
4604
+ *
4605
+ * @param userEnv - Environment variables from workflow config and orchestrator
4606
+ * @param options - Optional extended options for environment layers
4607
+ * @returns A new Record with only safe environment variables
4608
+ */
4609
+ function buildSanitizedEnv(userEnv, options) {
4610
+ const sanitized = {};
4611
+ if (options?.trustedEnv) Object.assign(sanitized, buildTrustedPassthroughEnv(process.env));
4612
+ else for (const key of ALLOWED_SYSTEM_VARS) {
4613
+ const value = process.env[key];
4614
+ if (value !== void 0) sanitized[key] = value;
4615
+ }
4616
+ Object.assign(sanitized, SANDBOX_DEFAULT_VARS);
4617
+ Object.assign(sanitized, userEnv);
4618
+ if (options?.contextVars) Object.assign(sanitized, options.contextVars);
4619
+ if (options?.jobEnv) Object.assign(sanitized, options.jobEnv);
4620
+ return sanitized;
4621
+ }
4622
+ var init_env_sanitizer = __esmMin((() => {}));
4623
+ //#endregion
4624
+ //#region src/execution/sandbox/eval-fork-runner.ts
4625
+ /**
4626
+ * Agent-side driver for the eval child.
4627
+ *
4628
+ * Forks `eval-runner.js` with a sanitized environment, sends one `eval`
4629
+ * request, streams the child's log lines onto the job's synthetic step-0 log,
4630
+ * and resolves with the evaluation's serialized result.
4631
+ *
4632
+ * The accepted inbound set is exactly `ready`, `log.line`, `eval.api.request`,
4633
+ * `eval.result` and `eval.error` — anything else from the child is ignored, so a
4634
+ * customer module that gains code execution in the child cannot reach the step
4635
+ * runner's privileged relays by naming one of their message types.
4636
+ */
4637
+ /**
4638
+ * Run one evaluation in a fresh child and resolve with its result.
4639
+ *
4640
+ * Rejects with the evaluation's own error text on `eval.error`, so the calling
4641
+ * handler reports exactly the message the in-process evaluation used to throw.
4642
+ */
4643
+ function runEvalChild(options) {
4644
+ const env = buildSanitizedEnv({}, { trustedEnv: options.trustedEnv });
4645
+ return new Promise((resolve, reject) => {
4646
+ let settled = false;
4647
+ const child = fork(options.evalRunnerPath, [], {
4648
+ env,
4649
+ cwd: options.request.workDir,
4650
+ stdio: [
4651
+ "pipe",
4652
+ "pipe",
4653
+ "pipe",
4654
+ "ipc"
4655
+ ]
4656
+ });
4657
+ const send = (msg) => {
4658
+ try {
4659
+ child.send(msg);
4660
+ } catch {}
4661
+ };
4662
+ const finish = (fn) => {
4663
+ if (settled) return;
4664
+ settled = true;
4665
+ clearTimeout(hardTimeout);
4666
+ options.signal?.removeEventListener("abort", onAbort);
4667
+ try {
4668
+ child.kill("SIGKILL");
4669
+ } catch {}
4670
+ fn();
4671
+ };
4672
+ const hardTimeout = setTimeout(() => {
4673
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child exceeded its ${EVAL_CHILD_HARD_TIMEOUT_MS}ms ceiling and was killed`)));
4674
+ }, EVAL_CHILD_HARD_TIMEOUT_MS);
4675
+ hardTimeout.unref?.();
4676
+ const onAbort = () => {
4677
+ finish(() => reject(/* @__PURE__ */ new Error("Evaluation cancelled")));
4678
+ };
4679
+ options.signal?.addEventListener("abort", onAbort, { once: true });
4680
+ child.stdout?.resume();
4681
+ let stderrTail = "";
4682
+ child.stderr?.setEncoding("utf8");
4683
+ child.stderr?.on("data", (chunk) => {
4684
+ stderrTail = (stderrTail + chunk).slice(-EVAL_CHILD_STDERR_TAIL_BYTES);
4685
+ });
4686
+ child.on("message", (msg) => {
4687
+ switch (msg.type) {
4688
+ case "ready":
4689
+ send({
4690
+ type: "eval",
4691
+ request: options.request
4692
+ });
4693
+ return;
4694
+ case "log.line":
4695
+ options.onLogLine(msg.line, msg.stream);
4696
+ return;
4697
+ case "eval.api.request":
4698
+ (options.onApiRequest ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available"))))(msg.method, msg.params).then((result) => send({
4699
+ type: "eval.api.response",
4700
+ id: msg.id,
4701
+ result
4702
+ }), (err) => send({
4703
+ type: "eval.api.response",
4704
+ id: msg.id,
4705
+ error: toErrorMessage(err)
4706
+ }));
4707
+ return;
4708
+ case "eval.result":
4709
+ finish(() => resolve(msg.result));
4710
+ return;
4711
+ case "eval.error":
4712
+ finish(() => reject(new Error(msg.error)));
4713
+ return;
4714
+ default: logger$10.warn("Ignoring unexpected message from eval child", { type: msg.type });
4715
+ }
4716
+ });
4717
+ child.on("error", (err) => {
4718
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child failed to start: ${err.message}`)));
4719
+ });
4720
+ child.on("exit", (code, signalName) => {
4721
+ const detail = stderrTail.trim();
4722
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child exited without a result (code=${code}, signal=${signalName})` + (detail ? `: ${detail}` : ""))));
4723
+ });
4724
+ });
4725
+ }
4726
+ var logger$10, EVAL_CHILD_HARD_TIMEOUT_MS, EVAL_CHILD_STDERR_TAIL_BYTES;
4727
+ var init_eval_fork_runner = __esmMin((() => {
4728
+ init_env_sanitizer();
4729
+ logger$10 = createLogger({ prefix: "eval-fork-runner" });
4730
+ EVAL_CHILD_HARD_TIMEOUT_MS = 18e5;
4731
+ EVAL_CHILD_STDERR_TAIL_BYTES = 4096;
5032
4732
  }));
5033
4733
  //#endregion
4734
+ //#region src/checkout/changed-files.ts
4735
+ /** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
4736
+ async function buildAuthCtx(auth) {
4737
+ if (!auth) return { args: [] };
4738
+ if (auth.kind === "basic") {
4739
+ const user = auth.user ?? "x-access-token";
4740
+ return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
4741
+ }
4742
+ const sshSetup = await setupSshAuth({
4743
+ privateKey: auth.secret,
4744
+ hostKeyPolicy: auth.sshHostKeyPolicy,
4745
+ knownHosts: auth.sshKnownHostsPem
4746
+ });
4747
+ return {
4748
+ args: [],
4749
+ env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
4750
+ cleanup: () => sshSetup.cleanup()
4751
+ };
4752
+ }
4753
+ function git(workDir, args, ctx) {
4754
+ return execFileSync("git", [
4755
+ ...ctx.args,
4756
+ ...BASE_GIT_ARGS,
4757
+ "-C",
4758
+ workDir,
4759
+ ...args
4760
+ ], {
4761
+ encoding: "utf8",
4762
+ stdio: [
4763
+ "ignore",
4764
+ "pipe",
4765
+ "pipe"
4766
+ ],
4767
+ ...ctx.env && { env: {
4768
+ ...process.env,
4769
+ ...ctx.env
4770
+ } }
4771
+ });
4772
+ }
4773
+ function tryGit(workDir, args, ctx) {
4774
+ try {
4775
+ git(workDir, args, ctx);
4776
+ return true;
4777
+ } catch {
4778
+ return false;
4779
+ }
4780
+ }
4781
+ function parseNameOnly(out) {
4782
+ return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
4783
+ }
4784
+ /** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
4785
+ function ensureCommit(workDir, commitish, ctx) {
4786
+ if (tryGit(workDir, [
4787
+ "cat-file",
4788
+ "-e",
4789
+ `${commitish}^{commit}`
4790
+ ], ctx)) return true;
4791
+ if (tryGit(workDir, [
4792
+ "fetch",
4793
+ "--depth",
4794
+ "1",
4795
+ "origin",
4796
+ commitish
4797
+ ], ctx)) {
4798
+ if (tryGit(workDir, [
4799
+ "cat-file",
4800
+ "-e",
4801
+ `${commitish}^{commit}`
4802
+ ], ctx)) return true;
4803
+ }
4804
+ for (let i = 0; i < MAX_DEEPEN; i++) {
4805
+ if (!tryGit(workDir, [
4806
+ "fetch",
4807
+ `--deepen=${DEEPEN_STEP}`,
4808
+ "origin"
4809
+ ], ctx)) break;
4810
+ if (tryGit(workDir, [
4811
+ "cat-file",
4812
+ "-e",
4813
+ `${commitish}^{commit}`
4814
+ ], ctx)) return true;
4815
+ }
4816
+ return false;
4817
+ }
4818
+ function pushDiff(workDir, before, ctx) {
4819
+ const isZero = !before || ZERO_SHA.test(before);
4820
+ const baseRef = isZero ? EMPTY_TREE_SHA : before;
4821
+ if (!isZero && !ensureCommit(workDir, before, ctx)) return {
4822
+ files: [],
4823
+ status: "unavailable"
4824
+ };
4825
+ return {
4826
+ files: parseNameOnly(git(workDir, [
4827
+ "diff",
4828
+ "--name-only",
4829
+ baseRef,
4830
+ "HEAD"
4831
+ ], ctx)),
4832
+ status: "fetched"
4833
+ };
4834
+ }
4835
+ function prDiff(workDir, base, ctx) {
4836
+ const candidates = [
4837
+ base,
4838
+ `origin/${base}`,
4839
+ "FETCH_HEAD"
4840
+ ];
4841
+ const resolveBase = () => candidates.find((c) => tryGit(workDir, [
4842
+ "rev-parse",
4843
+ "--verify",
4844
+ `${c}^{commit}`
4845
+ ], ctx));
4846
+ let baseRef = resolveBase();
4847
+ if (!baseRef) {
4848
+ if (!ensureCommit(workDir, base, ctx)) return {
4849
+ files: [],
4850
+ status: "unavailable"
4851
+ };
4852
+ baseRef = resolveBase();
4853
+ }
4854
+ if (!baseRef) return {
4855
+ files: [],
4856
+ status: "unavailable"
4857
+ };
4858
+ for (let i = 0; i <= MAX_DEEPEN; i++) {
4859
+ if (tryGit(workDir, [
4860
+ "merge-base",
4861
+ baseRef,
4862
+ "HEAD"
4863
+ ], ctx)) return {
4864
+ files: parseNameOnly(git(workDir, [
4865
+ "diff",
4866
+ "--name-only",
4867
+ `${baseRef}...HEAD`
4868
+ ], ctx)),
4869
+ status: "fetched"
4870
+ };
4871
+ if (!tryGit(workDir, [
4872
+ "fetch",
4873
+ `--deepen=${DEEPEN_STEP}`,
4874
+ "origin"
4875
+ ], ctx)) break;
4876
+ }
4877
+ return {
4878
+ files: [],
4879
+ status: "unavailable"
4880
+ };
4881
+ }
4882
+ /**
4883
+ * Compute the changed-files list from the agent's local clone (HEAD is the
4884
+ * checked-out head commit). Ground truth for job/step rule evaluation. `auth`
4885
+ * (the same credentials used for the clone) authenticates the deepen / fetch
4886
+ * calls so a private remote resolves. Returns `unavailable` for diff-less
4887
+ * events (schedule/tag/manual) or any git failure — never throws.
4888
+ */
4889
+ async function computeChangedFiles(workDir, event, auth) {
4890
+ let ctx;
4891
+ try {
4892
+ if (event.type !== "push" && event.type !== "pull_request") return {
4893
+ files: [],
4894
+ status: "unavailable"
4895
+ };
4896
+ ctx = await buildAuthCtx(auth);
4897
+ if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
4898
+ const base = event.baseBranch ?? event.targetBranch;
4899
+ if (!base) return {
4900
+ files: [],
4901
+ status: "unavailable"
4902
+ };
4903
+ return prDiff(workDir, base, ctx);
4904
+ } catch {
4905
+ return {
4906
+ files: [],
4907
+ status: "unavailable"
4908
+ };
4909
+ } finally {
4910
+ if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
4911
+ }
4912
+ }
4913
+ var EMPTY_TREE_SHA, ZERO_SHA, MAX_DEEPEN, DEEPEN_STEP, BASE_GIT_ARGS;
4914
+ var init_changed_files = __esmMin((() => {
4915
+ init_ssh_auth();
4916
+ EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
4917
+ ZERO_SHA = /^0+$/;
4918
+ MAX_DEEPEN = 4;
4919
+ DEEPEN_STEP = 50;
4920
+ BASE_GIT_ARGS = [
4921
+ "-c",
4922
+ "safe.directory=*",
4923
+ "-c",
4924
+ "core.quotePath=false"
4925
+ ];
4926
+ }));
4927
+ //#endregion
4928
+ //#region src/execution/global-workflow-env.ts
4929
+ /**
4930
+ * Derive an `owner/repo` identifier from a clone URL, stripping the trailing
4931
+ * `.git` and any `http(s)://host/` prefix.
4932
+ */
4933
+ function repoIdentifierFromUrl(repoUrl) {
4934
+ return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
4935
+ }
4936
+ var init_global_workflow_env = __esmMin((() => {}));
4937
+ //#endregion
5034
4938
  //#region src/execution/streaming-zx-log.ts
5035
4939
  /**
5036
4940
  * Shared factory for the zx `log` callback that streams a subprocess's
@@ -5072,471 +4976,155 @@ function makeStreamingZxLog(emit) {
5072
4976
  [LogStream.enum.stdout]: "",
5073
4977
  [LogStream.enum.stderr]: ""
5074
4978
  };
5075
- return (entry) => {
5076
- const e = entry;
5077
- if (e.kind !== "stdout" && e.kind !== "stderr") return;
5078
- if (!e.verbose) return;
5079
- const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
5080
- const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
5081
- const lines = (lineBufs[stream] + text).split("\n");
5082
- lineBufs[stream] = lines.pop();
5083
- for (const line of lines) if (line) emit(line, stream);
5084
- };
5085
- }
5086
- var init_streaming_zx_log = __esmMin((() => {}));
5087
- //#endregion
5088
- //#region src/execution/dynamic-job-serializer.ts
5089
- /**
5090
- * Convert an array of SDK Job objects into LockJob format for the orchestrator.
5091
- *
5092
- * @param jobs - Jobs returned by a DynamicJobFn
5093
- * @param ctx - Eval-time context used to resolve dynamic fields on generated jobs
5094
- * @param seenNames - Optional accumulator of job names already emitted earlier
5095
- * in the same eval round; when supplied, a name already present throws and
5096
- * every generated name is added so later generators in the round see it
5097
- * @returns Serialized LockJob array ready for orchestrator dispatch
5098
- * @throws Error if validation fails (duplicates, limit exceeded) or if a user-supplied
5099
- * dynamic function throws / times out / returns an unsupported value
5100
- */
5101
- async function serializeJobsToLock(jobs, ctx, staticNames, allowedGroups, seenNames) {
5102
- if (jobs.length > 100) throw new Error(`DynamicJobFn generated ${jobs.length} jobs, exceeding the limit of 100`);
5103
- const generatedNames = /* @__PURE__ */ new Set();
5104
- const roundNames = seenNames ?? /* @__PURE__ */ new Set();
5105
- for (const job of jobs) {
5106
- if (roundNames.has(job.name)) throw new Error(`Duplicate job name '${job.name}' in dynamic job output`);
5107
- generatedNames.add(job.name);
5108
- roundNames.add(job.name);
5109
- }
5110
- const result = [];
5111
- for (const job of jobs) result.push(await serializeJob(job, generatedNames, ctx, staticNames ?? /* @__PURE__ */ new Set(), allowedGroups ?? /* @__PURE__ */ new Set()));
5112
- return result;
5113
- }
5114
- async function serializeJob(job, generatedNames, ctx, staticNames, allowedGroups) {
5115
- const { include: runsOn, exclude: excludeLabels } = job.invoke && job.runsOn === void 0 ? {
5116
- include: [],
5117
- exclude: []
5118
- } : normalizeRunsOnToMatchers(job.runsOn, `generated job '${job.name}' runsOn`);
5119
- const envRefs = job.contexts ?? (job.context !== void 0 ? [job.context] : void 0);
5120
- let resolvedContexts;
5121
- if (envRefs !== void 0 && envRefs.length > 0) {
5122
- const resolved = [];
5123
- for (const ref of envRefs) if (typeof ref === "function") {
5124
- const value = await withTimeout(() => ref(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic environment for generated job '${job.name}'`);
5125
- if (value !== void 0 && value !== null) resolved.push({
5126
- value,
5127
- dynamic: false
5128
- });
5129
- } else if (typeof ref === "string") resolved.push({
5130
- value: ref,
5131
- dynamic: false
5132
- });
5133
- if (resolved.length > 0) resolvedContexts = resolved;
5134
- }
5135
- let resolvedEnv;
5136
- if (typeof job.env === "function") {
5137
- const value = await withTimeout(() => job.env(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic env for generated job '${job.name}'`);
5138
- if (value !== void 0 && value !== null) resolvedEnv = value;
5139
- } else if (job.env && typeof job.env === "object") resolvedEnv = job.env;
5140
- let resolvedConcurrencyGroup;
5141
- if (typeof job.concurrencyGroup === "function") {
5142
- const value = await withTimeout(() => job.concurrencyGroup(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic concurrencyGroup for generated job '${job.name}'`);
5143
- if (value !== void 0 && value !== null) resolvedConcurrencyGroup = value;
5144
- } else if (typeof job.concurrencyGroup === "string") resolvedConcurrencyGroup = job.concurrencyGroup;
5145
- const resolvedMatrix = job.matrix ? await serializeMatrix(job.matrix, job.name, runsOn, ctx) : void 0;
5146
- const resolvedNeeds = resolveNeeds(job.needs, generatedNames, staticNames, allowedGroups);
5147
- const dependsOnGroups = resolvedNeeds.filter((n) => typeof n === "object" && "group" in n).map((n) => n.group);
5148
- return {
5149
- _type: "static",
5150
- name: job.name,
5151
- ...job.invoke && runsOn.length === 0 ? {} : { runsOn },
5152
- ...excludeLabels.length > 0 ? { excludeLabels } : {},
5153
- needs: resolvedNeeds,
5154
- ...dependsOnGroups.length > 0 ? { dependsOnGroups } : {},
5155
- steps: serializeSteps(job.steps),
5156
- ...resolvedMatrix ? { matrix: resolvedMatrix } : {},
5157
- ...job.include ? { include: job.include } : {},
5158
- ...job.exclude ? { exclude: job.exclude } : {},
5159
- ...job.description ? { description: job.description } : {},
5160
- ...resolvedContexts !== void 0 ? { contexts: resolvedContexts } : {},
5161
- ...resolvedEnv !== void 0 ? { env: resolvedEnv } : {},
5162
- ...resolvedConcurrencyGroup !== void 0 ? { concurrencyGroup: resolvedConcurrencyGroup } : {},
5163
- ...job.invoke ? { invoke: {
5164
- event: job.invoke.event,
5165
- scope: job.invoke.scope,
5166
- ...job.invoke.payload !== void 0 ? { payload: job.invoke.payload } : {},
5167
- ...job.invoke.optional === true ? { optional: true } : {}
5168
- } } : {}
5169
- };
5170
- }
5171
- /**
5172
- * Resolve needs references. Jobs can reference other jobs by name (string),
5173
- * Job object reference, DynamicGroupRef, or NeedsEntry/NeedsGroupEntry objects.
5174
- * Validates against generatedNames union staticNames union allowedGroups.
5175
- *
5176
- * Returns the lock file representation: strings for concrete refs,
5177
- * NeedsEntry for { name, when }, NeedsGroupEntry for group refs (each `when`
5178
- * normalized to a runOn status-set).
5179
- */
5180
- function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
5181
- if (!needs || needs.length === 0) return [];
5182
- const allNames = /* @__PURE__ */ new Set([...generatedNames, ...staticNames]);
5183
- return needs.map((dep) => {
5184
- if (typeof dep === "string") {
5185
- if (!allNames.has(dep)) throw new Error(`Job dependency '${dep}' not found in workflow jobs (checked: ${generatedNames.size} generated, ${staticNames.size} static)`);
5186
- return dep;
5187
- }
5188
- if (isDynamicGroupRef(dep)) {
5189
- const groupRef = dep;
5190
- if (!allowedGroups.has(groupRef.group)) throw new Error(`Dynamic group '${groupRef.group}' not found in workflow (available groups: ${[...allowedGroups].join(", ") || "none"})`);
5191
- return {
5192
- group: groupRef.group,
5193
- runOn: resolveWhenToRunOn(groupRef.when)
5194
- };
5195
- }
5196
- if (typeof dep === "object" && dep !== null && "group" in dep) {
5197
- const groupDep = dep;
5198
- if (!allowedGroups.has(groupDep.group)) throw new Error(`Dynamic group '${groupDep.group}' not found in workflow (available groups: ${[...allowedGroups].join(", ") || "none"})`);
5199
- return {
5200
- group: groupDep.group,
5201
- runOn: resolveWhenToRunOn(groupDep.when)
5202
- };
5203
- }
5204
- if (typeof dep === "object" && dep !== null && "name" in dep && !("steps" in dep)) {
5205
- const namedDep = dep;
5206
- if (!allNames.has(namedDep.name)) throw new Error(`Job dependency '${namedDep.name}' not found in workflow jobs (checked: ${generatedNames.size} generated, ${staticNames.size} static)`);
5207
- return {
5208
- name: namedDep.name,
5209
- runOn: resolveWhenToRunOn(namedDep.when)
5210
- };
5211
- }
5212
- if (isDynamicJobFn(dep)) throw new Error("Job dependency cannot be a DynamicJobFn");
5213
- const name = dep.name;
5214
- if (!allNames.has(name)) throw new Error(`Job dependency '${name}' not found in workflow jobs (checked: ${generatedNames.size} generated, ${staticNames.size} static)`);
5215
- return name;
5216
- });
5217
- }
5218
- /**
5219
- * Serialize step definitions to lock file format.
5220
- * Steps are minimal in the lock file — just metadata. The actual run functions
5221
- * are loaded from the workflow bundle at execution time.
5222
- */
5223
- function serializeSteps(steps) {
5224
- let flatIndex = 0;
5225
- return steps.map((entry) => {
5226
- if (isParallelGroup(entry)) {
5227
- const children = entry.steps.map((child) => serializeSequentialStep(child, flatIndex++));
5228
- return {
5229
- kind: "parallel",
5230
- name: entry.name ?? `parallel-${children[0]?.name ?? "group"}`,
5231
- failFast: entry.failFast,
5232
- ...entry.maxParallel !== void 0 ? { maxParallel: entry.maxParallel } : {},
5233
- children
5234
- };
5235
- }
5236
- return serializeSequentialStep(entry, flatIndex++);
5237
- });
5238
- }
5239
- /** Serialize one sequential step (or bare function) to a flat `LockStep`. */
5240
- function serializeSequentialStep(stepOrFn, index) {
5241
- if (typeof stepOrFn === "function") return {
5242
- name: `step-${index}`,
5243
- hasOutputs: false
5244
- };
5245
- const step = stepOrFn;
5246
- return {
5247
- name: step.name || `step-${index}`,
5248
- hasOutputs: !!step.outputs,
5249
- ...step.continueOnError ? { continueOnError: true } : {},
5250
- ...step.timeout ? { timeout: step.timeout } : {},
5251
- ...step.retry ? { retry: {
5252
- maxAttempts: step.retry.maxAttempts,
5253
- delayMs: step.retry.delayMs,
5254
- backoff: step.retry.backoff,
5255
- maxDelayMs: step.retry.maxDelayMs
5256
- } } : {}
4979
+ return (entry) => {
4980
+ const e = entry;
4981
+ if (e.kind !== "stdout" && e.kind !== "stderr") return;
4982
+ if (!e.verbose) return;
4983
+ const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
4984
+ const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
4985
+ const lines = (lineBufs[stream] + text).split("\n");
4986
+ lineBufs[stream] = lines.pop();
4987
+ for (const line of lines) if (line) emit(line, stream);
5257
4988
  };
5258
4989
  }
4990
+ var init_streaming_zx_log = __esmMin((() => {}));
5259
4991
  /**
5260
- * Serialize matrix configuration. Static array/object matrices are embedded as-is;
5261
- * dynamic matrix functions are invoked against the eval context (mirroring the
5262
- * DynamicMatrixContext signature) and the resulting array/object is embedded.
4992
+ * Resolve the changed-files list a `filter` reads for a global eval round and
4993
+ * for a filter-bearing init job alike.
4994
+ *
4995
+ * Ground truth is the agent's own source clone; an already-`fetched` list from
4996
+ * the orchestrator is a free fast-path. A diff-less event (schedule / tag /
4997
+ * manual) resolves to `unavailable`, which makes `ctx.changedFiles` throw
4998
+ * rather than read as an empty diff — a `filter` returning false produces no
4999
+ * run at all, so a silently-empty diff would suppress the workflow with no
5000
+ * artifact anywhere to inspect.
5263
5001
  */
5264
- async function serializeMatrix(matrix, jobName, runsOn, ctx) {
5265
- if (isStaticArray(matrix)) return {
5266
- _type: "static",
5267
- values: matrix
5268
- };
5269
- if (isStaticObject(matrix)) return {
5270
- _type: "static",
5271
- values: matrix
5272
- };
5273
- const matrixCtx = {
5274
- $: ctx.$,
5275
- ctx: {
5276
- workflow: { name: ctx.workflowName },
5277
- job: {
5278
- name: jobName,
5279
- runsOn: runsOn.map((m) => m.kind === "exact" ? m.value : `/${m.source}/${m.flags}`)
5280
- }
5281
- },
5282
- log: ctx.log,
5283
- env: ctx.env
5284
- };
5285
- let values;
5286
- try {
5287
- values = await withTimeout(() => matrix(matrixCtx), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic matrix for generated job '${jobName}'`);
5288
- } catch (err) {
5289
- throw new MatrixExpansionError(jobName, `Matrix expansion failed for job '${jobName}': ${err.message}`);
5290
- }
5291
- if (Array.isArray(values)) return {
5292
- _type: "static",
5293
- values
5294
- };
5295
- if (values && typeof values === "object") return {
5296
- _type: "static",
5297
- values
5002
+ async function resolveEvalChangedFiles(dispatch, event, sourceDir) {
5003
+ const ev = event;
5004
+ if (ev.changedFilesStatus === "fetched") return {
5005
+ files: ev.changedFiles ?? [],
5006
+ status: "fetched"
5298
5007
  };
5299
- throw new MatrixExpansionError(jobName, `Job '${jobName}': dynamic matrix function returned an unsupported value (expected array or object, got ${typeof values})`);
5008
+ return computeChangedFiles(sourceDir, event, dispatch.sourceAuth ?? dispatch.workflowAuth ?? (dispatch.token ? {
5009
+ kind: "basic",
5010
+ user: "x-access-token",
5011
+ secret: dispatch.token
5012
+ } : void 0));
5300
5013
  }
5301
- var MatrixExpansionError, DYNAMIC_FIELD_TIMEOUT_MS;
5302
- var init_dynamic_job_serializer = __esmMin((() => {
5303
- init_timeout_util();
5304
- MatrixExpansionError = class MatrixExpansionError extends Error {
5305
- jobName;
5306
- name = "MatrixExpansionError";
5307
- constructor(jobName, message) {
5308
- super(message);
5309
- this.jobName = jobName;
5310
- Object.setPrototypeOf(this, MatrixExpansionError.prototype);
5311
- }
5312
- };
5313
- DYNAMIC_FIELD_TIMEOUT_MS = 6e4;
5314
- }));
5315
- //#endregion
5316
- //#region src/execution/global-workflow-env.ts
5317
5014
  /**
5318
- * Derive an `owner/repo` identifier from a clone URL, stripping the trailing
5319
- * `.git` and any `http(s)://host/` prefix.
5015
+ * Materialize the source tree a non-global workflow's `filter` reads through
5016
+ * `ctx.sourceRepo.path`.
5017
+ *
5018
+ * An init or dynamic-eval job normally restores only `.kici/` from the cached
5019
+ * source tarball — enough to import the workflow module, but a directory with no
5020
+ * repo in it. A filter that reads a file or shells out against that path would
5021
+ * get a confidently wrong answer, and `changedFiles` could not be computed at
5022
+ * all, so a filter-bearing job clones the source repo into a sibling directory.
5023
+ *
5024
+ * When no tarball was attached the job already cloned the whole repo into
5025
+ * `workDir`, and that clone is reused rather than duplicated — including the
5026
+ * local working-tree case, where there is no repo url and `workDir` IS the tree.
5027
+ *
5028
+ * A tarball with no repo url is the one combination that cannot be honoured:
5029
+ * `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
5030
+ * would hand the filter a directory in which every path test answers "absent" —
5031
+ * the exact silent lie this function exists to prevent — so it throws instead.
5320
5032
  */
5321
- function repoIdentifierFromUrl(repoUrl) {
5322
- return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
5323
- }
5324
- /**
5325
- * Inject the seven global-workflow env keys and return a restorer that puts
5326
- * `process.env` back exactly as it was — each key reset to its prior value, or
5327
- * deleted if it had none.
5328
- *
5329
- * **The restorer is mandatory for any caller in a long-lived process.** The
5330
- * sandbox may ignore it: it runs one job per forked child, which exits. The
5331
- * pre-dispatch global eval round may NOT: it runs in the agent process, which
5332
- * serves many dispatches from one `JobRunner`. Leaving the keys set there is
5333
- * this module's own hazard running backwards — a later NON-global
5334
- * `DynamicJobFn` evaluation builds its generator context with
5335
- * `env: process.env` still carrying `KICI_IS_GLOBAL_WORKFLOW=true` and a
5336
- * `KICI_SOURCE_REPO_PATH` pointing at a deleted work directory, while that
5337
- * job's own sandbox re-evaluation sees neither (`buildSanitizedEnv` scrubs the
5338
- * whole `KICI_*` namespace on the trusted profile, and the default profile is
5339
- * allowlist-only). That is the same two-worlds determinism failure, injected
5340
- * into an unrelated job.
5341
- *
5342
- * `RepoInfo.ref` / `.sha` are optional, so an evaluation with no checkout
5343
- * metadata writes an empty string rather than leaving the key unset — matching
5344
- * how `KICI_WORKFLOW_REPO` already handles a missing identifier. Assigning
5345
- * `undefined` to a `process.env` key would stringify to `"undefined"`, which is
5346
- * worse than either.
5347
- */
5348
- function applyGlobalWorkflowEnv(repos) {
5349
- const prior = GLOBAL_WORKFLOW_ENV_KEYS.map((key) => [key, process.env[key]]);
5350
- process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
5351
- process.env.KICI_WORKFLOW_REPO_PATH = repos.workflowRepo.path;
5352
- process.env.KICI_SOURCE_REPO_PATH = repos.sourceRepo.path;
5353
- process.env.KICI_SOURCE_REPO = repos.sourceRepo.identifier;
5354
- process.env.KICI_SOURCE_BRANCH = repos.sourceRepo.ref ?? "";
5355
- process.env.KICI_SOURCE_SHA = repos.sourceRepo.sha ?? "";
5356
- process.env.KICI_WORKFLOW_REPO = repos.workflowRepo.identifier;
5357
- return () => {
5358
- for (const [key, value] of prior) if (value === void 0) delete process.env[key];
5359
- else process.env[key] = value;
5360
- };
5361
- }
5362
- var GLOBAL_WORKFLOW_ENV_KEYS;
5363
- var init_global_workflow_env = __esmMin((() => {
5364
- GLOBAL_WORKFLOW_ENV_KEYS = [
5365
- "KICI_IS_GLOBAL_WORKFLOW",
5366
- "KICI_WORKFLOW_REPO_PATH",
5367
- "KICI_SOURCE_REPO_PATH",
5368
- "KICI_SOURCE_REPO",
5369
- "KICI_SOURCE_BRANCH",
5370
- "KICI_SOURCE_SHA",
5371
- "KICI_WORKFLOW_REPO"
5372
- ];
5373
- }));
5374
- //#endregion
5375
- //#region src/execution/global-eval-runner.ts
5376
- function buildRoundState(args) {
5377
- const loadModule = args.loadModule ?? (async (sourceFile) => (await loadWorkflowSource(args.workflowDir, sourceFile)).module);
5378
- return {
5379
- args,
5380
- $: args.$ ?? $,
5381
- log: args.log ?? NOOP_LOG,
5382
- kici: args.kici ?? buildKiciApi(() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available"))),
5383
- loadModule,
5384
- moduleCache: /* @__PURE__ */ new Map()
5385
- };
5386
- }
5387
- /** Load a workflow, caching per source file so a shared module is imported once. */
5388
- async function loadWorkflowCached(shared, sourceFile, workflowName) {
5389
- let pending = shared.moduleCache.get(sourceFile);
5390
- if (!pending) {
5391
- pending = shared.loadModule(sourceFile);
5392
- shared.moduleCache.set(sourceFile, pending);
5393
- }
5394
- return extractWorkflow(await pending, workflowName);
5033
+ async function ensureFilterSourceDir(dispatch, workDir) {
5034
+ if (!dispatch.sourceTarUrl) return workDir;
5035
+ if (!dispatch.repoUrl) throw new Error("Workflow declares a filter, but this job restored its source from the cache with no repo url to clone from — the filter would see an empty tree. Re-run with a source repository configured, or remove the filter.");
5036
+ const sourceDir = join(workDir, FILTER_SOURCE_DIRNAME);
5037
+ const sourceAuth = dispatch.sourceAuth;
5038
+ await gitClone({
5039
+ repoUrl: dispatch.repoUrl,
5040
+ ref: dispatch.ref,
5041
+ sha: dispatch.sha,
5042
+ workDir: sourceDir,
5043
+ gitAuth: sourceAuth,
5044
+ token: sourceAuth ? void 0 : dispatch.token
5045
+ });
5046
+ return sourceDir;
5395
5047
  }
5396
5048
  /**
5397
- * Run every `DynamicJobFn` the workflow declares and serialize the result.
5049
+ * Build the context a non-global workflow's `filter` is evaluated against.
5050
+ *
5051
+ * `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
5052
+ * means — so both carry the same identifier, path, ref, and sha. The zx shell is
5053
+ * rooted at the source tree and streams into the evaluating step's log, matching
5054
+ * what the global eval round hands its own filters.
5398
5055
  *
5399
- * The generator context is built through `buildGeneratorContext` with the same
5400
- * repo pair the sandbox re-evaluation gets, so the two calls a generator
5401
- * receives cannot drift apart. Returns `undefined` when the workflow declares
5402
- * no generators, which keeps the `jobs` key off the wire entirely.
5056
+ * They are two distinct objects all the same. Being the same repo is a fact
5057
+ * about their VALUES, not a licence to hand the author one object under two
5058
+ * names: a filter that mutated `ctx.sourceRepo` would silently see
5059
+ * `ctx.workflowRepo` change with it, which happens on no other path.
5403
5060
  */
5404
- async function generateDynamicJobs(workflow, shared) {
5405
- const { args } = shared;
5406
- const generators = workflow.jobs.filter(isDynamicJobFn);
5407
- if (generators.length === 0) return void 0;
5408
- const serializerCtx = {
5409
- event: args.event,
5410
- $: shared.$,
5411
- log: shared.log,
5412
- env: process.env,
5413
- workflowName: workflow.name
5061
+ async function buildInitFilterInput(dispatch, event, workDir, emit) {
5062
+ const sourceDir = await ensureFilterSourceDir(dispatch, workDir);
5063
+ const diff = await resolveEvalChangedFiles(dispatch, event, sourceDir);
5064
+ const repo = {
5065
+ identifier: repoIdentifierFromUrl(dispatch.repoUrl),
5066
+ path: sourceDir,
5067
+ ref: dispatch.ref,
5068
+ sha: dispatch.sha
5414
5069
  };
5415
- const jobs = [];
5416
- const seenNames = /* @__PURE__ */ new Set();
5417
- for (const generator of generators) {
5418
- const generated = await generator(buildGeneratorContext({
5419
- workflowName: workflow.name,
5420
- event: args.event,
5421
- env: process.env,
5422
- repos: args.repos,
5423
- $: shared.$,
5424
- log: shared.log,
5425
- kici: shared.kici
5426
- }));
5427
- jobs.push(...await serializeJobsToLock(generated, serializerCtx, void 0, void 0, seenNames));
5428
- }
5429
- return jobs;
5430
- }
5431
- /**
5432
- * Evaluate one candidate to a verdict: run its `filter` if it declares one,
5433
- * then its generators if it survives.
5434
- */
5435
- async function evaluateCandidateInner(candidate, shared) {
5436
- const { args } = shared;
5437
- const workflow = await loadWorkflowCached(shared, candidate.sourceFile, candidate.workflowName);
5438
- if (candidate.hasFilter) {
5439
- if (typeof workflow.filter !== "function") throw new Error(`Workflow '${candidate.workflowName}' is recorded as declaring a filter, but its module exports none — the lock file is out of date. Run 'kici compile' and commit the result.`);
5440
- const filterCtx = createFilterContext({
5441
- sourceRepo: args.repos.sourceRepo,
5442
- workflowRepo: args.repos.workflowRepo,
5443
- event: args.event,
5444
- changedFiles: args.changedFiles,
5445
- changedFilesStatus: args.changedFilesStatus,
5446
- env: process.env,
5447
- $: shared.$
5448
- });
5449
- if (!await workflow.filter(filterCtx)) return {
5450
- workflowName: candidate.workflowName,
5451
- run: false
5452
- };
5453
- }
5454
- const jobs = await generateDynamicJobs(workflow, shared);
5455
5070
  return {
5456
- workflowName: candidate.workflowName,
5457
- run: true,
5458
- ...jobs && { jobs }
5071
+ sourceRepo: repo,
5072
+ workflowRepo: { ...repo },
5073
+ changedFiles: diff.files,
5074
+ changedFilesStatus: diff.status,
5075
+ env: process.env,
5076
+ $: await buildEvalShell(sourceDir, emit)
5459
5077
  };
5460
5078
  }
5461
5079
  /**
5462
- * Evaluate one candidate, never throwing. A failure a throwing filter, a
5463
- * broken generator, a blown per-candidate budget becomes an indeterminate
5464
- * verdict so the round's other candidates still get real answers.
5465
- */
5466
- async function evaluateCandidate(candidate, shared) {
5467
- try {
5468
- return await withTimeout(() => evaluateCandidateInner(candidate, shared), shared.args.candidateTimeoutMs, `global workflow '${candidate.workflowName}'`);
5469
- } catch (error) {
5470
- return {
5471
- workflowName: candidate.workflowName,
5472
- run: false,
5473
- indeterminate: true,
5474
- reason: error instanceof Error ? error.message : String(error)
5475
- };
5476
- }
5477
- }
5478
- /**
5479
- * Evaluate candidates one at a time — they share one checkout and one working
5480
- * directory, so a parallel `$` would race on cwd.
5080
+ * Build the per-invocation zx `$` a global eval round hands to filters and
5081
+ * generators, so a `await $\`…\`` inside one is visible in the eval step's log.
5082
+ *
5083
+ * **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
5084
+ * snapshot taken when the shell is built, which is before the round applies the
5085
+ * seven `KICI_*` keys — so a filter that shells out (`$\`printenv
5086
+ * KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
5087
+ * here while the sandbox re-evaluation's ambient `$` resolves `process.env`
5088
+ * after `setupGlobalWorkflowEnv` has run and does see them. That is the same
5089
+ * two-worlds determinism failure the cwd choice below exists to prevent, one
5090
+ * layer down. Passing the live reference reproduces the ambient `$`'s own
5091
+ * behaviour, which is what the sandbox uses.
5481
5092
  *
5482
- * Stops before starting a candidate once the round deadline has passed or the
5483
- * caller aborted. That check is what keeps the sequential guarantee meaningful
5484
- * past a timeout: `withTimeout` races rather than cancels, so without it the
5485
- * loop would keep launching every remaining candidate — each up to
5486
- * `candidateTimeoutMs` — into a work directory the job has already reported on
5487
- * and whose cleanup has already deleted. With it, at most one candidate is ever
5488
- * in flight past the deadline.
5093
+ * `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
5094
+ * decrypted secret never leaks into the log.
5489
5095
  *
5490
- * Results are appended to the caller's array as they land, so a round that
5491
- * blows its own budget can still report the verdicts it did establish rather
5492
- * than discarding the work.
5096
+ * `emit` is a callback rather than the `LogStreamer` itself so the caller can
5097
+ * route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
5098
+ * flag and `addLine` buffers unconditionally, so a subprocess line arriving
5099
+ * after the step was reported would otherwise emit a `log.chunk` for a terminal
5100
+ * step. That is the likeliest path for it — an orphaned candidate is usually
5101
+ * orphaned *because* it is waiting on a subprocess.
5493
5102
  */
5494
- async function evaluateAllCandidates(shared, into, deadline) {
5495
- for (const candidate of shared.args.candidates) {
5496
- if (shared.args.signal?.aborted || Date.now() >= deadline) return;
5497
- into.push(await evaluateCandidate(candidate, shared));
5498
- }
5103
+ async function buildEvalShell(cwd, emit) {
5104
+ const { $: zx$ } = await import("zx");
5105
+ return zx$({
5106
+ cwd,
5107
+ env: process.env,
5108
+ verbose: true,
5109
+ quiet: false,
5110
+ log: makeStreamingZxLog(emit)
5111
+ });
5499
5112
  }
5500
5113
  /**
5501
- * Run one global eval round and return every candidate's verdict, in candidate
5502
- * order. Never throws: a round that exceeds `roundTimeoutMs` reports whatever
5503
- * it established and marks the rest indeterminate.
5114
+ * Build the result-aware `ctx.needs` for a dynamic eval from its frozen upstream
5115
+ * snapshot. Returns undefined for an event-only generator (no snapshot).
5504
5116
  */
5505
- async function runGlobalEvalRound(args) {
5506
- const restoreEnv = applyGlobalWorkflowEnv(args.repos);
5507
- const settled = [];
5508
- let stopReason;
5509
- try {
5510
- const shared = buildRoundState(args);
5511
- await withTimeout(() => evaluateAllCandidates(shared, settled, Date.now() + args.roundTimeoutMs), args.roundTimeoutMs, `global eval round (${args.candidates.length} candidate(s))`);
5512
- } catch (error) {
5513
- stopReason = error instanceof Error ? error.message : String(error);
5514
- } finally {
5515
- restoreEnv();
5516
- }
5517
- const candidates = [...settled];
5518
- const reason = stopReason ?? (args.signal?.aborted ? "global eval round was cancelled before this candidate was evaluated" : "global eval round deadline reached before this candidate was evaluated");
5519
- for (const candidate of args.candidates.slice(candidates.length)) candidates.push({
5520
- workflowName: candidate.workflowName,
5521
- run: false,
5522
- indeterminate: true,
5523
- reason
5524
- });
5525
- return { candidates };
5117
+ function buildEvalNeedsContext(config) {
5118
+ if (!config.resultAware || !config.upstreamSnapshot) return void 0;
5119
+ return buildNeedsContext(config.upstreamSnapshot, config.declaredNeeds ?? []);
5526
5120
  }
5527
- var NOOP_LOG;
5528
- var init_global_eval_runner = __esmMin((() => {
5529
- init_timeout_util();
5530
- init_generator_context();
5121
+ var FILTER_SOURCE_DIRNAME;
5122
+ var init_eval_context = __esmMin((() => {
5123
+ init_git_clone();
5124
+ init_changed_files();
5531
5125
  init_global_workflow_env();
5532
- init_workflow_loader();
5533
- init_dynamic_job_serializer();
5534
- NOOP_LOG = {
5535
- info: () => {},
5536
- warn: () => {},
5537
- error: () => {},
5538
- debug: () => {}
5539
- };
5126
+ init_streaming_zx_log();
5127
+ FILTER_SOURCE_DIRNAME = "__kici_filter_source__";
5540
5128
  })), DEFAULT_MAX_LOG_SIZE_BYTES, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_LINE_THRESHOLD, PAUSE_SAFETY_TIMEOUT_MS, LogStreamer;
5541
5129
  var init_log_streamer = __esmMin((() => {
5542
5130
  init_prometheus();
@@ -5953,7 +5541,7 @@ async function applyOverlay(config) {
5953
5541
  const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
5954
5542
  const { path: tmpDir, cleanup } = await makeTempDir("overlay");
5955
5543
  try {
5956
- logger$8.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
5544
+ logger$9.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
5957
5545
  let encryptedData;
5958
5546
  try {
5959
5547
  encryptedData = await downloadUrl(tarballUrl);
@@ -5964,7 +5552,7 @@ async function applyOverlay(config) {
5964
5552
  const orchPrivKeyBuf = Buffer.from(orchestratorPrivateKey, "base64");
5965
5553
  const aesKey = deriveSharedSecret(orchPrivKeyBuf, cliPubKeyBuf);
5966
5554
  const decryptedData = decryptBuffer(encryptedData, aesKey);
5967
- logger$8.info("Extracting overlay tarball", { size: decryptedData.length });
5555
+ logger$9.info("Extracting overlay tarball", { size: decryptedData.length });
5968
5556
  const extractDir = path.join(tmpDir, "extracted");
5969
5557
  await fsPromises.mkdir(extractDir, { recursive: true });
5970
5558
  try {
@@ -6013,10 +5601,10 @@ async function applyOverlay(config) {
6013
5601
  await fsPromises.unlink(targetPath);
6014
5602
  filesDeleted++;
6015
5603
  } catch {
6016
- logger$8.debug("Deletion target not found, skipping", { file });
5604
+ logger$9.debug("Deletion target not found, skipping", { file });
6017
5605
  }
6018
5606
  }
6019
- logger$8.info("Overlay applied successfully", {
5607
+ logger$9.info("Overlay applied successfully", {
6020
5608
  filesApplied,
6021
5609
  filesDeleted
6022
5610
  });
@@ -6029,10 +5617,10 @@ async function applyOverlay(config) {
6029
5617
  await cleanup().catch(() => {});
6030
5618
  }
6031
5619
  }
6032
- var logger$8, IV_LENGTH$1, AUTH_TAG_LENGTH;
5620
+ var logger$9, IV_LENGTH$1, AUTH_TAG_LENGTH;
6033
5621
  var init_overlay_applier = __esmMin((() => {
6034
5622
  init_download();
6035
- logger$8 = createLogger({ prefix: "overlay-applier" });
5623
+ logger$9 = createLogger({ prefix: "overlay-applier" });
6036
5624
  IV_LENGTH$1 = 12;
6037
5625
  AUTH_TAG_LENGTH = 16;
6038
5626
  }));
@@ -6168,10 +5756,12 @@ var init_npm_registry_config = __esmMin((() => {}));
6168
5756
  *
6169
5757
  * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
6170
5758
  * (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
6171
- * workflow-loader work unchanged. `enableScripts: false` (when a private
6172
- * registry is configured) keeps dependency lifecycle scripts from seeing the
6173
- * synthesized token env vars — the same security model as npm/pnpm/classic
6174
- * `--ignore-scripts`.
5759
+ * workflow-loader work unchanged. `enableScripts: false` keeps dependency
5760
+ * lifecycle scripts from running at all for every install, not only one
5761
+ * against a private registry — the same security model as npm/pnpm/classic
5762
+ * `--ignore-scripts`. An operator opts back in with
5763
+ * `KICI_ALLOW_INSTALL_SCRIPTS=true`, which arrives here as
5764
+ * `ignoreScripts: false`.
6175
5765
  *
6176
5766
  * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
6177
5767
  * shapes as the npm overlay so `dep-installer` can pick either by flavor.
@@ -6218,8 +5808,8 @@ async function applyYarnrcBerryConfig(args) {
6218
5808
  };
6219
5809
  const tokenEnv = {};
6220
5810
  const tokensForRedaction = [];
5811
+ if (args.ignoreScripts !== false) merged.enableScripts = false;
6221
5812
  if (hasPrivateRegistry) {
6222
- merged.enableScripts = false;
6223
5813
  const npmScopes = { ...doc.npmScopes ?? {} };
6224
5814
  for (let i = 0; i < registries.length; i++) {
6225
5815
  const reg = registries[i];
@@ -6561,9 +6151,11 @@ var init_workspace_siblings = __esmMin((() => {}));
6561
6151
  * Security: the install runs with an isolated per-invocation cache/store
6562
6152
  * directory to prevent cache poisoning across build jobs — a malicious
6563
6153
  * package.json in one repo cannot taint the cache used by subsequent builds.
6564
- * The same pressure rules out letting lifecycle scripts see synthesized auth
6565
- * env vars the install runs with `--ignore-scripts` whenever a private
6566
- * registry is configured.
6154
+ * The install runs with `--ignore-scripts` for every package manager. A
6155
+ * lifecycle script in a committed `package.json` is customer code the agent
6156
+ * never agreed to execute: it would run wherever the install runs, which for a
6157
+ * step-child install is the process holding the job's secrets. Operators who
6158
+ * genuinely need it set `KICI_ALLOW_INSTALL_SCRIPTS=true` on the agent.
6567
6159
  */
6568
6160
  /**
6569
6161
  * Detect the package manager for the cloned repo from its committed manifests.
@@ -6594,9 +6186,11 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
6594
6186
  * between build jobs; the directory is removed after installation.
6595
6187
  *
6596
6188
  * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
6597
- * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
6598
- * and the install runs with `--ignore-scripts` so lifecycle scripts in a
6599
- * committed `package.json` cannot exfiltrate the synthesized token env vars.
6189
+ * `.kici/.npmrc` overlay is synthesized for the install and restored in
6190
+ * `finally`.
6191
+ *
6192
+ * Lifecycle scripts are disabled for every package manager unless the operator
6193
+ * set `opts.allowInstallScripts`.
6600
6194
  *
6601
6195
  * @param kiciDir - Path to the `.kici/` directory containing package.json.
6602
6196
  * @param opts - Optional registry / installEnv / repoRoot configuration.
@@ -6605,7 +6199,7 @@ async function installDeps(kiciDir, opts = {}) {
6605
6199
  const repoRoot = opts.repoRoot ?? dirname(kiciDir);
6606
6200
  const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
6607
6201
  const yarnFlavor = packageManager === PackageManager.Yarn ? await detectKiciYarnFlavor(repoRoot, kiciDir) : YarnFlavor.Classic;
6608
- logger$7.info("Installing deps inline", {
6202
+ logger$8.info("Installing deps inline", {
6609
6203
  packageManager,
6610
6204
  yarnFlavor,
6611
6205
  dir: kiciDir
@@ -6618,13 +6212,15 @@ async function installDeps(kiciDir, opts = {}) {
6618
6212
  yarnFlavor
6619
6213
  });
6620
6214
  const startTime = Date.now();
6621
- const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
6215
+ const ignoreScripts = opts.allowInstallScripts !== true;
6216
+ const baseEnv = opts.baseEnv ?? process.env;
6622
6217
  const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
6623
6218
  const registryConfig = isBerry ? await applyYarnrcBerryConfig({
6624
6219
  kiciDir,
6625
6220
  npmRegistries: opts.npmRegistries,
6626
6221
  installEnvSecrets: opts.installEnvSecrets,
6627
- jobIdShort: opts.jobIdShort ?? "00000000"
6222
+ jobIdShort: opts.jobIdShort ?? "00000000",
6223
+ ignoreScripts
6628
6224
  }) : await applyNpmRegistryConfig({
6629
6225
  kiciDir,
6630
6226
  npmRegistries: opts.npmRegistries,
@@ -6634,22 +6230,26 @@ async function installDeps(kiciDir, opts = {}) {
6634
6230
  try {
6635
6231
  if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
6636
6232
  kiciDir,
6637
- hasPrivateRegistry,
6638
- registryConfig
6233
+ ignoreScripts,
6234
+ registryConfig,
6235
+ baseEnv
6639
6236
  });
6640
6237
  else if (isBerry) await runYarnBerryInstall({
6641
6238
  kiciDir,
6642
- registryConfig
6239
+ registryConfig,
6240
+ baseEnv
6643
6241
  });
6644
6242
  else if (packageManager === PackageManager.Yarn) await runYarnInstall({
6645
6243
  kiciDir,
6646
- hasPrivateRegistry,
6647
- registryConfig
6244
+ ignoreScripts,
6245
+ registryConfig,
6246
+ baseEnv
6648
6247
  });
6649
6248
  else await runNpmInstall({
6650
6249
  kiciDir,
6651
- hasPrivateRegistry,
6652
- registryConfig
6250
+ ignoreScripts,
6251
+ registryConfig,
6252
+ baseEnv
6653
6253
  });
6654
6254
  } catch (e) {
6655
6255
  const tokens = registryConfig.tokensForRedaction;
@@ -6659,29 +6259,35 @@ async function installDeps(kiciDir, opts = {}) {
6659
6259
  } finally {
6660
6260
  await registryConfig.cleanup();
6661
6261
  }
6662
- if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
6663
- if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
6262
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot, baseEnv);
6263
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv);
6664
6264
  const durationMs = Date.now() - startTime;
6665
6265
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
6666
- logger$7.info("Deps installed inline", {
6266
+ logger$8.info("Deps installed inline", {
6667
6267
  packageManager,
6668
6268
  durationMs
6669
6269
  });
6670
6270
  }
6671
- /** Build the Node binary directory onto PATH so spawned tools find `node`. */
6672
- function envWithNodeOnPath(extraEnv, nodeDir) {
6673
- const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
6271
+ /**
6272
+ * Build the Node binary directory onto PATH so spawned tools find `node`.
6273
+ *
6274
+ * `baseEnv` is the caller's declared environment for the subprocess. It
6275
+ * defaults to `process.env` because inside the runner child that IS the
6276
+ * sanitized job environment; an agent-process caller passes a sanitized base.
6277
+ */
6278
+ function envWithNodeOnPath(extraEnv, nodeDir, baseEnv = process.env) {
6279
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = baseEnv;
6674
6280
  return {
6675
6281
  ...restEnv,
6676
6282
  ...extraEnv,
6677
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
6283
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${restEnv.PATH ?? ""}`
6678
6284
  };
6679
6285
  }
6680
6286
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
6681
6287
  async function runNpmInstall(args) {
6682
6288
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
6683
6289
  const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
6684
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6290
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6685
6291
  const buildArgs = (...prefix) => {
6686
6292
  const a = [
6687
6293
  ...prefix,
@@ -6691,7 +6297,7 @@ async function runNpmInstall(args) {
6691
6297
  "--no-audit",
6692
6298
  "--no-fund"
6693
6299
  ];
6694
- if (args.hasPrivateRegistry) a.push("--ignore-scripts");
6300
+ if (args.ignoreScripts) a.push("--ignore-scripts");
6695
6301
  return a;
6696
6302
  };
6697
6303
  try {
@@ -6720,7 +6326,7 @@ async function runPnpmInstall(args) {
6720
6326
  await assertPnpmAvailable();
6721
6327
  const { nodeDir } = resolveNpm();
6722
6328
  const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
6723
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6329
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6724
6330
  const argv = [
6725
6331
  "install",
6726
6332
  `--config.store-dir=${storeDir}`,
@@ -6729,7 +6335,7 @@ async function runPnpmInstall(args) {
6729
6335
  "--config.side-effects-cache=false",
6730
6336
  PNPM_IGNORE_BUILD_GATE_ARG
6731
6337
  ];
6732
- if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
6338
+ if (args.ignoreScripts) argv.push("--ignore-scripts");
6733
6339
  try {
6734
6340
  process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
6735
6341
  await execFileAsync("pnpm", argv, {
@@ -6743,7 +6349,7 @@ async function runPnpmInstall(args) {
6743
6349
  }
6744
6350
  }
6745
6351
  /** Pure: argv for `yarn install` with an isolated cache folder. */
6746
- function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
6352
+ function buildYarnInstallArgs(cacheDir, ignoreScripts) {
6747
6353
  const a = [
6748
6354
  "install",
6749
6355
  "--cache-folder",
@@ -6751,7 +6357,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
6751
6357
  "--non-interactive",
6752
6358
  "--no-progress"
6753
6359
  ];
6754
- if (hasPrivateRegistry) a.push("--ignore-scripts");
6360
+ if (ignoreScripts) a.push("--ignore-scripts");
6755
6361
  return a;
6756
6362
  }
6757
6363
  /**
@@ -6766,8 +6372,8 @@ async function runYarnInstall(args) {
6766
6372
  await assertYarnAvailable();
6767
6373
  const { nodeDir } = resolveNpm();
6768
6374
  const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
6769
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6770
- const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
6375
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6376
+ const argv = buildYarnInstallArgs(cacheDir, args.ignoreScripts);
6771
6377
  try {
6772
6378
  process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
6773
6379
  await execFileAsync("yarn", argv, {
@@ -6797,7 +6403,7 @@ async function runYarnBerryInstall(args) {
6797
6403
  await assertYarnAvailable();
6798
6404
  const { nodeDir } = resolveNpm();
6799
6405
  const env = {
6800
- ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
6406
+ ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv),
6801
6407
  COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
6802
6408
  };
6803
6409
  const argv = buildYarnBerryInstallArgs();
@@ -6828,11 +6434,11 @@ async function assertYarnAvailable() {
6828
6434
  * Deep cross-sibling build chains may build out of strict topological order —
6829
6435
  * real `.kici` closures are shallow.
6830
6436
  */
6831
- async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
6437
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv) {
6832
6438
  const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
6833
6439
  if (siblings.length === 0) return;
6834
6440
  const { nodeDir } = resolveNpm();
6835
- const env = envWithNodeOnPath({}, nodeDir);
6441
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
6836
6442
  for (const rel of [...siblings].reverse()) {
6837
6443
  const sibDir = join(repoRoot, rel);
6838
6444
  if (!await siblingHasBuildScript(sibDir)) continue;
@@ -6876,9 +6482,9 @@ async function siblingHasBuildScript(sibDir) {
6876
6482
  * the subprocess stderr/stdout is folded into the thrown error so the job's
6877
6483
  * failure message names the real cause instead of a bare "Command failed".
6878
6484
  */
6879
- async function buildWorkspaceClosure(repoRoot) {
6485
+ async function buildWorkspaceClosure(repoRoot, baseEnv) {
6880
6486
  const { nodeDir } = resolveNpm();
6881
- const env = envWithNodeOnPath({}, nodeDir);
6487
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
6882
6488
  const argv = [
6883
6489
  "--filter",
6884
6490
  "{.kici}^...",
@@ -6920,13 +6526,13 @@ function logSubprocessStreams(e, tokens) {
6920
6526
  if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
6921
6527
  if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
6922
6528
  }
6923
- var logger$7, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
6529
+ var logger$8, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
6924
6530
  var init_dep_installer = __esmMin((() => {
6925
6531
  init_npm_registry_config();
6926
6532
  init_yarnrc_berry_config();
6927
6533
  init_validate_kici_deps();
6928
6534
  init_workspace_siblings();
6929
- logger$7 = createLogger({ prefix: "dep-installer" });
6535
+ logger$8 = createLogger({ prefix: "dep-installer" });
6930
6536
  execFileAsync = promisify(execFile);
6931
6537
  INSTALL_TIMEOUT_MS = 6e5;
6932
6538
  INSTALL_MAX_BUFFER = 134217728;
@@ -6974,7 +6580,7 @@ async function packNodeModules(kiciDir) {
6974
6580
  const nmRoot = packageManager === PackageManager.Yarn ? resolveYarnNodeModulesRoot(workDir, kiciDir) : join(kiciDir, "node_modules");
6975
6581
  if (!existsSync(nmRoot)) throw new Error(`node_modules not found at ${nmRoot}`);
6976
6582
  const entries = await closureEntries(workDir, kiciDir, packageManager);
6977
- logger$6.info("Packing dependency closure into tarball", {
6583
+ logger$7.info("Packing dependency closure into tarball", {
6978
6584
  dir: workDir,
6979
6585
  packageManager,
6980
6586
  entries
@@ -6990,7 +6596,7 @@ async function packNodeModules(kiciDir) {
6990
6596
  const tarball = Buffer.concat(chunks);
6991
6597
  const hash = sha256(tarball);
6992
6598
  const sizeMB = (tarball.length / 1048576).toFixed(2);
6993
- logger$6.info("Dependency closure packed", {
6599
+ logger$7.info("Dependency closure packed", {
6994
6600
  sizeMB,
6995
6601
  hash: hash.slice(0, 12),
6996
6602
  durationMs: Date.now() - startTime
@@ -7024,73 +6630,109 @@ async function closureEntries(workDir, kiciDir, packageManager) {
7024
6630
  }
7025
6631
  return [relative(workDir, join(kiciDir, "node_modules"))];
7026
6632
  }
7027
- var logger$6;
6633
+ var logger$7;
7028
6634
  var init_dep_packer = __esmMin((() => {
7029
6635
  init_workspace_siblings();
7030
- logger$6 = createLogger({ prefix: "dep-packer" });
6636
+ logger$7 = createLogger({ prefix: "dep-packer" });
7031
6637
  }));
7032
6638
  //#endregion
7033
- //#region src/execution/sandbox/env-sanitizer.ts
6639
+ //#region src/execution/sandbox/log-masker.ts
7034
6640
  /**
7035
- * Environment sanitization for sandbox execution.
7036
- *
7037
- * Uses an explicit allowlist approach: only known-safe system variables are
7038
- * copied from the host process.env. Agent-internal credentials (KICI_*,
7039
- * DATABASE_URL, PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are NEVER included.
7040
- *
7041
- * 7-layer merge precedence (later overrides earlier):
7042
- * 1. Allowed system vars from process.env
7043
- * 2. Sandbox defaults (FORCE_COLOR=1, etc.)
7044
- * 3. KICI_* system vars (orchestrator-generated, from userEnv)
7045
- * 4. Org-level environment vars (from orchestrator via contextVars)
7046
- * 5. Source-level environment overrides (merged into contextVars by orchestrator)
7047
- * 6. Job env (from lock file env field, evaluated by orchestrator)
7048
- * 7. setEnv() calls (runtime -- applied at step execution, not here)
6641
+ * Escape regex special characters in a string.
7049
6642
  */
6643
+ function escapeRegExp(s) {
6644
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6645
+ }
7050
6646
  /**
7051
- * Build a sanitized environment for sandbox execution.
7052
- *
7053
- * Constructs an environment from scratch using the 7-layer merge:
7054
- * 1. Explicitly allowed system variables from process.env
7055
- * 2. Sandbox default variables (FORCE_COLOR, etc.)
7056
- * 3. User-defined env vars (KICI_* system vars from orchestrator)
7057
- * 4-5. Environment vars (org-level + source overrides, pre-merged by orchestrator)
7058
- * 6. Job env (SDK-defined, evaluated by orchestrator)
7059
- * 7. setEnv() calls (runtime -- not handled here)
7060
- *
7061
- * Secrets are NOT injected into environment variables by this function.
7062
- * They flow through IPC to ctx.secrets and are only exposed via ctx.secrets.expose().
7063
- *
7064
- * Agent-internal credentials (KICI_ORCHESTRATOR_URL, DATABASE_URL,
7065
- * PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are never included because they
7066
- * are not in the allowlist.
6647
+ * Create a LogMasker initialized with all secret values from the request.
7067
6648
  *
7068
- * The `trustedEnv` option (default false) selects the trusted fleet-agent
7069
- * profile: Layer 1 passes the ambient host env through instead of restricting
7070
- * to `ALLOWED_SYSTEM_VARS`, minus the agent's own KiCI identity/operational
7071
- * secrets (the whole `KICI_*` namespace + a small non-`KICI_` infra denylist).
7072
- * It is read ONLY from agent config (`KICI_TRUSTED_ENV`), never from a dispatch
7073
- * payload, so a workflow cannot request it. With it OFF the output is
7074
- * byte-identical to the allowlist-only behavior.
6649
+ * Collects values from both flat secrets and all namespaced context secrets,
6650
+ * deduplicating before registration.
7075
6651
  *
7076
- * @param userEnv - Environment variables from workflow config and orchestrator
7077
- * @param options - Optional extended options for environment layers
7078
- * @returns A new Record with only safe environment variables
6652
+ * Both the runner child and the agent-side fork runner build a masker from the
6653
+ * same request, so the crash tail the agent assembles from the child's stderr is
6654
+ * masked with the same value set the child used for its own log lines.
7079
6655
  */
7080
- function buildSanitizedEnv(userEnv, options) {
7081
- const sanitized = {};
7082
- if (options?.trustedEnv) Object.assign(sanitized, buildTrustedPassthroughEnv(process.env));
7083
- else for (const key of ALLOWED_SYSTEM_VARS) {
7084
- const value = process.env[key];
7085
- if (value !== void 0) sanitized[key] = value;
7086
- }
7087
- Object.assign(sanitized, SANDBOX_DEFAULT_VARS);
7088
- Object.assign(sanitized, userEnv);
7089
- if (options?.contextVars) Object.assign(sanitized, options.contextVars);
7090
- if (options?.jobEnv) Object.assign(sanitized, options.jobEnv);
7091
- return sanitized;
6656
+ function createSecretMasker(request) {
6657
+ const masker = new LogMasker();
6658
+ const allSecrets = {};
6659
+ if (request.secrets) Object.assign(allSecrets, request.secrets);
6660
+ if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
6661
+ masker.registerSecrets(allSecrets);
6662
+ return masker;
7092
6663
  }
7093
- var init_env_sanitizer = __esmMin((() => {}));
6664
+ var MIN_MASK_LENGTH, LogMasker;
6665
+ var init_log_masker = __esmMin((() => {
6666
+ MIN_MASK_LENGTH = 3;
6667
+ LogMasker = class {
6668
+ pattern = null;
6669
+ /**
6670
+ * Register secret values to be masked in log output.
6671
+ *
6672
+ * Values shorter than 3 characters are skipped to avoid false positives.
6673
+ * Base64-encoded variants of each qualifying secret are also registered,
6674
+ * preventing leaks when secrets appear base64-encoded in logs (e.g.,
6675
+ * Authorization: Basic headers, base64-encoded config values).
6676
+ * Values are sorted by length descending so longer values are matched first
6677
+ * (prevents partial masking when one secret is a substring of another).
6678
+ *
6679
+ * Multi-line values additionally register each of their individual lines.
6680
+ * Log output is split into lines before it reaches the masker, so a value
6681
+ * containing a newline can never match as a whole — a PEM private key or a
6682
+ * kubeconfig would otherwise stream in clear text. Two consequences of the
6683
+ * per-line registration are deliberate:
6684
+ *
6685
+ * - `MIN_MASK_LENGTH` is 3, so short structural lines of a structured secret
6686
+ * are registered too. A `---` YAML separator or a bare `{` from a
6687
+ * service-account JSON is masked wherever it appears in that job's logs.
6688
+ * - PEM header and footer lines (`-----BEGIN OPENSSH PRIVATE KEY-----`) are
6689
+ * not secret on their own and are masked as a side effect.
6690
+ *
6691
+ * Both are strictly safer than leaking the body, and no heuristic separates a
6692
+ * structural line from a body line without risking the reverse mistake.
6693
+ */
6694
+ registerSecrets(secrets) {
6695
+ const seen = /* @__PURE__ */ new Set();
6696
+ const values = [];
6697
+ const add = (candidate) => {
6698
+ if (candidate.length < MIN_MASK_LENGTH || seen.has(candidate)) return;
6699
+ seen.add(candidate);
6700
+ values.push(candidate);
6701
+ const b64 = Buffer.from(candidate).toString("base64");
6702
+ if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
6703
+ seen.add(b64);
6704
+ values.push(b64);
6705
+ }
6706
+ };
6707
+ for (const value of Object.values(secrets)) {
6708
+ add(value);
6709
+ if (value.includes("\n")) for (const rawLine of value.split("\n")) add(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
6710
+ }
6711
+ if (values.length === 0) {
6712
+ this.pattern = null;
6713
+ return;
6714
+ }
6715
+ values.sort((a, b) => b.length - a.length);
6716
+ this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
6717
+ }
6718
+ /**
6719
+ * Mask all registered secret values in a log line.
6720
+ *
6721
+ * Returns the line unchanged if no secrets are registered.
6722
+ */
6723
+ mask(line) {
6724
+ if (!this.pattern) return line;
6725
+ this.pattern.lastIndex = 0;
6726
+ return line.replace(this.pattern, "***");
6727
+ }
6728
+ /**
6729
+ * Returns true if any maskable secrets are registered.
6730
+ */
6731
+ hasSecrets() {
6732
+ return this.pattern !== null;
6733
+ }
6734
+ };
6735
+ }));
7094
6736
  //#endregion
7095
6737
  //#region src/execution/sandbox/secret-encryption.ts
7096
6738
  /**
@@ -7221,8 +6863,10 @@ function buildRequest(dispatch, workDir, extra) {
7221
6863
  sourceAuth: dispatch.sourceAuth,
7222
6864
  workflowAuth: dispatch.workflowAuth,
7223
6865
  ...extra?.credentialHelperPath ? { credentialHelperPath: extra.credentialHelperPath } : {},
6866
+ ...extra?.allowInstallScripts ? { allowInstallScripts: true } : {},
7224
6867
  sourceTarUrl: dispatch.sourceTarUrl,
7225
6868
  sourceTarHash: dispatch.sourceTarHash,
6869
+ sourceTarDigest: dispatch.sourceTarDigest,
7226
6870
  depsUrl: dispatch.depsUrl,
7227
6871
  depsHash: dispatch.depsHash,
7228
6872
  workflowName: jobConfig.workflowName ?? "",
@@ -7252,6 +6896,7 @@ function buildRequest(dispatch, workDir, extra) {
7252
6896
  cliPublicKey: jobConfig.cliPublicKey,
7253
6897
  orchestratorPrivateKey: jobConfig.orchestratorPrivateKey,
7254
6898
  runPublicKey: dispatch.runPublicKey,
6899
+ provenanceContext: dispatch.provenanceContext,
7255
6900
  context: jobConfig.context,
7256
6901
  contextVars: jobConfig.contextVars,
7257
6902
  jobEnv: jobConfig.jobEnv,
@@ -7398,6 +7043,56 @@ function fileCloneSourceBinds(repoUrl) {
7398
7043
  }
7399
7044
  }
7400
7045
  /**
7046
+ * Resolve a `KICI_RUNNER_USER` spec to numeric ids.
7047
+ *
7048
+ * Accepts `uid`, `uid:gid`, or a user name. A name is resolved through `id`,
7049
+ * which is present on every POSIX host the bare-metal backend supports; a uid
7050
+ * with no gid takes that user's primary group.
7051
+ *
7052
+ * Throws on an unresolvable spec rather than silently falling back to the
7053
+ * agent's own uid — the operator set this to get a boundary, so quietly not
7054
+ * having one is the worst outcome.
7055
+ */
7056
+ function resolveRunnerUser(spec) {
7057
+ if (!spec) return void 0;
7058
+ const [userPart, groupPart] = spec.split(":");
7059
+ const runId = (args) => {
7060
+ const out = execFileSync("id", args, { encoding: "utf8" }).trim();
7061
+ if (!out) throw new Error(`empty output from \`id ${args.join(" ")}\``);
7062
+ return out;
7063
+ };
7064
+ try {
7065
+ const uid = /^\d+$/.test(userPart ?? "") ? Number(userPart) : Number(runId(["-u", userPart]));
7066
+ const gid = groupPart === void 0 ? Number(runId(["-g", userPart])) : /^\d+$/.test(groupPart) ? Number(groupPart) : Number(runId(["-g", groupPart]));
7067
+ if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error("non-numeric id");
7068
+ return {
7069
+ uid,
7070
+ gid
7071
+ };
7072
+ } catch (err) {
7073
+ throw new Error(`KICI_RUNNER_USER='${spec}' could not be resolved to a uid/gid: ${toErrorMessage(err)}`);
7074
+ }
7075
+ }
7076
+ /**
7077
+ * Hand the job workdir to the runner user before the child starts.
7078
+ *
7079
+ * Without this the child cannot write its own workspace. A failure is fatal:
7080
+ * the operator asked for a dedicated uid, and running one that cannot write is
7081
+ * a broken job wearing the clothes of a boundary.
7082
+ */
7083
+ function chownWorkDirToRunner(workDir, identity) {
7084
+ if (!workDir) return;
7085
+ try {
7086
+ execFileSync("chown", [
7087
+ "-R",
7088
+ `${identity.uid}:${identity.gid}`,
7089
+ workDir
7090
+ ], { stdio: "pipe" });
7091
+ } catch (err) {
7092
+ throw new Error(`failed to chown job workdir ${workDir} to ${identity.uid}:${identity.gid} for KICI_RUNNER_USER: ${toErrorMessage(err)}`);
7093
+ }
7094
+ }
7095
+ /**
7401
7096
  * Spawn the workflow-runner child process. Returns the child handle and a
7402
7097
  * boolean indicating whether the spawn produced a usable PID. On Windows
7403
7098
  * services (shawl) fork() can fail silently when IPC pipes cannot be set up;
@@ -7405,6 +7100,12 @@ function fileCloneSourceBinds(repoUrl) {
7405
7100
  */
7406
7101
  function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7407
7102
  const { runnerPath, useBwrap = false, networkIsolation = false, extraReadOnlyBinds = [] } = options;
7103
+ const identity = resolveRunnerUser(options.runnerUser);
7104
+ if (identity) chownWorkDirToRunner(effectiveWorkDir, identity);
7105
+ const identityOpts = identity ? {
7106
+ uid: identity.uid,
7107
+ gid: identity.gid
7108
+ } : {};
7408
7109
  let child;
7409
7110
  if (useBwrap) {
7410
7111
  const bwrapArgs = buildBwrapArgs(effectiveWorkDir, process.execPath, networkIsolation, runnerPath, extraReadOnlyBinds);
@@ -7419,7 +7120,8 @@ function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7419
7120
  "pipe",
7420
7121
  "pipe",
7421
7122
  "ipc"
7422
- ]
7123
+ ],
7124
+ ...identityOpts
7423
7125
  });
7424
7126
  } else child = fork(runnerPath, [], {
7425
7127
  env: sanitizedEnv,
@@ -7430,13 +7132,29 @@ function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7430
7132
  "ipc"
7431
7133
  ],
7432
7134
  cwd: effectiveWorkDir || void 0,
7433
- detached: options.detachProcessGroup === true
7135
+ detached: options.detachProcessGroup === true,
7136
+ ...identityOpts
7434
7137
  });
7435
7138
  return {
7436
7139
  child,
7437
7140
  pidAssigned: Boolean(child.pid)
7438
7141
  };
7439
7142
  }
7143
+ /**
7144
+ * True when the operator asked for the child's raw stdio on the agent's own
7145
+ * stderr (`KICI_RUNNER_DEBUG_STDIO=true`).
7146
+ *
7147
+ * Off by default. The agent's stderr is not a run log: for a scaler-spawned
7148
+ * agent the orchestrator's LogForwarder copies it into journald and Loki, where
7149
+ * it is readable without the `runs:read` check that guards the run log and with
7150
+ * no per-org scoping. The child's masked `log.line` stream is the log; echoing
7151
+ * the same bytes to stderr would publish the unmasked copy beside it.
7152
+ *
7153
+ * Read per call rather than cached so a test can flip it between cases.
7154
+ */
7155
+ function debugStdioEnabled() {
7156
+ return process.env.KICI_RUNNER_DEBUG_STDIO === "true";
7157
+ }
7440
7158
  /** Drain the child's stdout (preventing pipe-buffer deadlock) and capture
7441
7159
  * the last N stderr lines for crash diagnostics. The returned array is
7442
7160
  * mutated in place by the stderr listener. */
@@ -7444,7 +7162,7 @@ function setupChildStdioCapture(child) {
7444
7162
  const stderrLines = [];
7445
7163
  const MAX_STDERR_LINES = 20;
7446
7164
  if (child.stdout) child.stdout.on("data", (chunk) => {
7447
- process.stderr.write(`[workflow-runner:stdout] ${chunk.toString().trimEnd()}\n`);
7165
+ if (debugStdioEnabled()) process.stderr.write(`[workflow-runner:stdout] ${chunk.toString().trimEnd()}\n`);
7448
7166
  });
7449
7167
  if (child.stderr) {
7450
7168
  let stderrBuffer = "";
@@ -7456,7 +7174,7 @@ function setupChildStdioCapture(child) {
7456
7174
  if (!line.trim()) continue;
7457
7175
  stderrLines.push(line);
7458
7176
  if (stderrLines.length > MAX_STDERR_LINES) stderrLines.shift();
7459
- process.stderr.write(`[workflow-runner:stderr] ${line}\n`);
7177
+ if (debugStdioEnabled()) process.stderr.write(`[workflow-runner:stderr] ${line}\n`);
7460
7178
  }
7461
7179
  });
7462
7180
  }
@@ -7629,7 +7347,7 @@ function handleJobComplete(msg, dispatch, ctx) {
7629
7347
  /** Dispatch one IPC message from the runner. Centralises the message switch
7630
7348
  * so the createForkRunner body stays small. */
7631
7349
  function relayChildIpcMessage(msg, dispatch, ctx) {
7632
- const logDetail = msg.type === "log.line" ? ` content="${msg.line}"` : msg.type === "job.complete" ? ` status=${msg.status}` : "";
7350
+ const logDetail = msg.type === "log.line" ? debugStdioEnabled() ? ` content="${msg.line}"` : "" : msg.type === "job.complete" ? ` status=${msg.status}` : "";
7633
7351
  process.stderr.write(`[fork-runner] IPC message received: type=${msg.type}${logDetail}\n`);
7634
7352
  switch (msg.type) {
7635
7353
  case "ready":
@@ -7637,7 +7355,8 @@ function relayChildIpcMessage(msg, dispatch, ctx) {
7637
7355
  type: "execute",
7638
7356
  request: buildRequest(dispatch, ctx.effectiveWorkDir, {
7639
7357
  cleanupOnly: ctx.cleanupOnly,
7640
- credentialHelperPath: ctx.execOptions.credentialHelperPath
7358
+ credentialHelperPath: ctx.execOptions.credentialHelperPath,
7359
+ allowInstallScripts: ctx.execOptions.allowInstallScripts
7641
7360
  })
7642
7361
  });
7643
7362
  return;
@@ -7719,9 +7438,10 @@ function handleChildExitWithoutCompletion(code, signal, ctx) {
7719
7438
  process.stderr.write(`[fork-runner] Child exited: code=${code}, signal=${signal}, jobCompleted=${ctx.state.jobCompleted}\n`);
7720
7439
  if (ctx.state.jobCompleted) return;
7721
7440
  const isCancelled = ctx.state.forkState === "cancelling" || ctx.state.forkState === "force_killing";
7722
- const crashMsg = isCancelled ? "Job cancelled" : ctx.stderrLines.length > 0 ? `Workflow runner crashed:\n${ctx.stderrLines.join("\n")}` : `Workflow runner exited unexpectedly (code=${code}, signal=${signal})`;
7441
+ const stderrLines = ctx.stderrLines.map((line) => ctx.masker.mask(line));
7442
+ const crashMsg = isCancelled ? "Job cancelled" : stderrLines.length > 0 ? `Workflow runner crashed:\n${stderrLines.join("\n")}` : `Workflow runner exited unexpectedly (code=${code}, signal=${signal})`;
7723
7443
  if (!isCancelled) {
7724
- ctx.execOptions.onLogLine(-1, `[sandbox] Child exited: code=${code}, signal=${signal}, stderr=${ctx.stderrLines.slice(-5).join(" | ")}`);
7444
+ ctx.execOptions.onLogLine(-1, `[sandbox] Child exited: code=${code}, signal=${signal}, stderr=${stderrLines.slice(-5).join(" | ")}`);
7725
7445
  ctx.execOptions.onLogLine(-1, `[sandbox] ${crashMsg}`);
7726
7446
  }
7727
7447
  ctx.resolve({
@@ -7781,6 +7501,7 @@ function buildCancelFn(child, ctx, defaultGracePeriodMs, agentMaxGracePeriodMs,
7781
7501
  function createForkRunner(options, execOptions) {
7782
7502
  const sanitizedEnv = buildSanitizedEnv(options.env);
7783
7503
  if (options.useBwrap) sanitizedEnv.TMPDIR = "/tmp";
7504
+ if (debugStdioEnabled()) sanitizedEnv.KICI_RUNNER_DEBUG_STDIO = "true";
7784
7505
  const effectiveWorkDir = options.workDir ?? "/workspace";
7785
7506
  const dispatch = execOptions.dispatch;
7786
7507
  const { child, pidAssigned } = spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir);
@@ -7828,6 +7549,10 @@ function createForkRunner(options, execOptions) {
7828
7549
  cancelTimers,
7829
7550
  state: sharedState,
7830
7551
  stderrLines,
7552
+ masker: createSecretMasker(buildRequest(dispatch, effectiveWorkDir, {
7553
+ cleanupOnly: options.cleanupOnly === true,
7554
+ credentialHelperPath: execOptions.credentialHelperPath
7555
+ })),
7831
7556
  cleanupOnly: options.cleanupOnly === true
7832
7557
  };
7833
7558
  process.stderr.write(`[fork-runner] Child process spawned: pid=${child.pid}, runner=${options.runnerPath}, cwd=${effectiveWorkDir}\n`);
@@ -7874,6 +7599,7 @@ function createForkRunner(options, execOptions) {
7874
7599
  };
7875
7600
  }
7876
7601
  var init_fork_runner = __esmMin((() => {
7602
+ init_log_masker();
7877
7603
  init_env_sanitizer();
7878
7604
  init_secret_encryption();
7879
7605
  }));
@@ -7895,16 +7621,17 @@ var init_fork_runner = __esmMin((() => {
7895
7621
  * network access. This mode provides credential isolation only and should
7896
7622
  * be used in trusted environments.
7897
7623
  */
7898
- var logger$5, BareMetalSandbox;
7624
+ var logger$6, BareMetalSandbox;
7899
7625
  var init_bare_metal_sandbox = __esmMin((() => {
7900
7626
  init_fork_runner();
7901
- logger$5 = createLogger({ prefix: "bare-metal-sandbox" });
7627
+ logger$6 = createLogger({ prefix: "bare-metal-sandbox" });
7902
7628
  BareMetalSandbox = class {
7903
7629
  runnerPath;
7904
7630
  useBwrap;
7905
7631
  sandboxNetwork;
7906
7632
  env;
7907
7633
  orphanCleanup;
7634
+ runnerUser;
7908
7635
  runner = null;
7909
7636
  workDir;
7910
7637
  lastOptions = null;
@@ -7914,6 +7641,7 @@ var init_bare_metal_sandbox = __esmMin((() => {
7914
7641
  this.sandboxNetwork = options.sandboxNetwork ?? "isolated";
7915
7642
  this.env = options.env;
7916
7643
  this.orphanCleanup = options.orphanCleanup ?? true;
7644
+ this.runnerUser = options.runnerUser;
7917
7645
  }
7918
7646
  /**
7919
7647
  * Validate that the runner path exists and bwrap is available (if needed).
@@ -7928,12 +7656,12 @@ var init_bare_metal_sandbox = __esmMin((() => {
7928
7656
  if (this.useBwrap) try {
7929
7657
  const { execSync } = await import("node:child_process");
7930
7658
  execSync("which bwrap", { stdio: "ignore" });
7931
- if (this.sandboxNetwork === "isolated") logger$5.info("Bubblewrap (bwrap) sandbox enabled with network isolation (--unshare-net)");
7932
- else logger$5.info("Bubblewrap (bwrap) sandbox enabled with host network (KICI_SANDBOX_NETWORK=host)");
7659
+ if (this.sandboxNetwork === "isolated") logger$6.info("Bubblewrap (bwrap) sandbox enabled with network isolation (--unshare-net)");
7660
+ else logger$6.info("Bubblewrap (bwrap) sandbox enabled with host network (KICI_SANDBOX_NETWORK=host)");
7933
7661
  } catch {
7934
7662
  throw new Error("Bubblewrap (bwrap) not found. Install bubblewrap or set sandbox=false. On Debian/Ubuntu: apt install bubblewrap");
7935
7663
  }
7936
- else logger$5.warn("Bare-metal without sandbox provides limited isolation. Only environment sanitization is active. Enable sandbox=true with bubblewrap for PID/IPC/filesystem namespace isolation.");
7664
+ else logger$6.warn("Bare-metal without sandbox provides limited isolation. Only environment sanitization is active. Enable sandbox=true with bubblewrap for PID/IPC/filesystem namespace isolation.");
7937
7665
  }
7938
7666
  /**
7939
7667
  * Execute a job by forking the workflow runner with sanitized environment.
@@ -7948,7 +7676,8 @@ var init_bare_metal_sandbox = __esmMin((() => {
7948
7676
  workDir: this.workDir,
7949
7677
  networkIsolation: this.useBwrap && this.sandboxNetwork === "isolated",
7950
7678
  extraReadOnlyBinds,
7951
- detachProcessGroup: this.orphanCleanup && !this.useBwrap
7679
+ detachProcessGroup: this.orphanCleanup && !this.useBwrap,
7680
+ ...this.runnerUser ? { runnerUser: this.runnerUser } : {}
7952
7681
  }, options);
7953
7682
  return this.runner.result;
7954
7683
  }
@@ -7998,7 +7727,8 @@ var init_bare_metal_sandbox = __esmMin((() => {
7998
7727
  workDir,
7999
7728
  networkIsolation: this.useBwrap && this.sandboxNetwork === "isolated",
8000
7729
  cleanupOnly: true,
8001
- maxGracePeriodMs: 5e3
7730
+ maxGracePeriodMs: 5e3,
7731
+ ...this.runnerUser ? { runnerUser: this.runnerUser } : {}
8002
7732
  }, {
8003
7733
  ...opts,
8004
7734
  signal,
@@ -8164,6 +7894,7 @@ function buildContainerHardening(opts) {
8164
7894
  const network = grant?.network ?? opts.networkMode;
8165
7895
  if (network === "none") hostConfig.NetworkMode = "none";
8166
7896
  else if (network === "host") hostConfig.NetworkMode = "host";
7897
+ else if (opts.jobNetworkName) hostConfig.NetworkMode = opts.jobNetworkName;
8167
7898
  const user = grant?.user ?? opts.user;
8168
7899
  return user ? {
8169
7900
  hostConfig,
@@ -8176,6 +7907,233 @@ var init_container_hardening = __esmMin((() => {
8176
7907
  NO_NEW_PRIVILEGES = ["no-new-privileges"];
8177
7908
  }));
8178
7909
  //#endregion
7910
+ //#region src/execution/sandbox/job-network.ts
7911
+ /**
7912
+ * Resolve what a job container may reach on the host.
7913
+ *
7914
+ * The default is DNS on the bridge gateway and nothing else. A job container
7915
+ * talks to the agent through `docker exec` rather than over the network, and it
7916
+ * holds none of the agent's credentials, so name resolution is the one host
7917
+ * service it genuinely needs. That carve-out is not cosmetic: rootful podman
7918
+ * runs its resolver on the gateway, a host address, so a container with no such
7919
+ * rule cannot resolve any name at all. Docker answers inside the container's
7920
+ * own netns and is unaffected either way.
7921
+ *
7922
+ * The set is derived HERE, from the agent's own configuration, and takes no
7923
+ * argument that could widen it. A `NetworkPolicy` reaching
7924
+ * {@link applyJobEgressRules} governs the FORWARD hook only — what the job
7925
+ * reaches through the host — so its `hostAccess` is deliberately not consulted:
7926
+ * a job that could name its own host reachability is the escape hatch this
7927
+ * boundary exists to close, and a signature that cannot carry one is what makes
7928
+ * that structural rather than a convention.
7929
+ */
7930
+ function resolveJobHostAccess(gateway = JOB_NETWORK_GATEWAY) {
7931
+ return [`${gateway}:${DNS_PORT}`];
7932
+ }
7933
+ /**
7934
+ * Ensure the `kici-jobs` network exists, the nftables table is ready, and the
7935
+ * subnet's default egress drops are installed.
7936
+ *
7937
+ * The drops land here — before any job container is created — and are keyed on
7938
+ * the subnet rather than on a container address, so a customer image's own
7939
+ * `ENTRYPOINT` never runs unfiltered.
7940
+ *
7941
+ * Returns whether egress filtering is actually in force. A host without `nft`
7942
+ * or without `NET_ADMIN` — a rootless agent, most commonly — warns loudly and
7943
+ * degrades to the network with no rules rather than refusing to run any job:
7944
+ * `kici-admin agent install` is a compat-protected surface, and hard-requiring
7945
+ * NET_ADMIN would break every existing rootless install with no additive path.
7946
+ * The same trade-off the scaler already made for agent containers.
7947
+ */
7948
+ async function ensureJobNetwork(docker) {
7949
+ try {
7950
+ await validateNftablesAvailability();
7951
+ await ensureKiciTable();
7952
+ await ensureJobSubnetEgressRules(JOB_NETWORK_SUBNET, JOB_NETWORK_GATEWAY);
7953
+ const actual = await ensureJobNetworkExists(docker);
7954
+ if (actual.subnet !== JOB_NETWORK_SUBNET || actual.gateway !== "172.31.0.1") {
7955
+ logger$5.warn(`Job network ${JOB_NETWORK_NAME} carries ${actual.subnet} (gateway ${actual.gateway}), not the expected ${JOB_NETWORK_SUBNET} (gateway ${JOB_NETWORK_GATEWAY}). Installing the egress drops for the range it actually uses. Remove the network to have the agent recreate it with the expected addressing.`);
7956
+ await ensureJobSubnetEgressRules(actual.subnet, actual.gateway);
7957
+ }
7958
+ return true;
7959
+ } catch (err) {
7960
+ logger$5.warn(`Job-container egress filtering DISABLED: ${toErrorMessage(err)} Job containers reach the host private network and the cloud metadata endpoint. Grant the agent --cap-add=NET_ADMIN (and install nftables), or set KICI_SANDBOX_NETWORK_ISOLATION=false to silence this.`);
7961
+ return false;
7962
+ }
7963
+ }
7964
+ /**
7965
+ * Install the default RFC1918 + cloud-metadata drops for the whole job subnet.
7966
+ *
7967
+ * Keyed on `ip saddr 172.31.0.0/16`, so one rule set covers every container
7968
+ * that will ever join the bridge — including one that does not exist yet. A
7969
+ * customer image's own `ENTRYPOINT` therefore starts behind the drops: there is
7970
+ * no window between the container running and its rules landing, because the
7971
+ * rules do not depend on the container.
7972
+ *
7973
+ * The per-container rules {@link applyJobEgressRules} adds stay the layer above
7974
+ * these, and stay effective: `addIsolationRules` inserts at the chain head, so
7975
+ * a rule keyed on one container's address is evaluated before the subnet set
7976
+ * and a per-job `accept` still wins over the subnet `drop`. The exception is a
7977
+ * repair (below), which puts a fresh subnet set at the head — above the
7978
+ * per-container rules of jobs that were already running. That reordering has no
7979
+ * effect while `applyJobEgressRules` is called with no policy, since the two
7980
+ * sets then hold identical verdicts; a per-job policy would have to reinstall
7981
+ * its own rules after a repair to keep winning.
7982
+ *
7983
+ * Idempotent, because `ensureJobNetwork` runs once per job and an unconditional
7984
+ * install would leak a rule set per job: the chain is read first and the install
7985
+ * is skipped when it already carries the set.
7986
+ *
7987
+ * The skip requires EVERY expected rule, never merely one of them. The install
7988
+ * is one `nft insert` per rule, so a failure or a kill part-way through
7989
+ * leaves some of the set in the chain; a check that skipped on the first match
7990
+ * would then read a chain missing the `10.0.0.0/8` drop as covered, and every
7991
+ * later job on that host would join the bridge behind permanently partial
7992
+ * filtering with nothing to heal it. Comparing against the rule text
7993
+ * {@link buildIsolationRuleOps} emits is also what rejects the two near-misses
7994
+ * the scaler writes into this same chain — a per-container rule at
7995
+ * `172.31.0.10`, and an operator allowlist naming this subnet as a DESTINATION.
7996
+ *
7997
+ * A partial set is repaired by installing the whole set again rather than by
7998
+ * deleting first: the fresh block lands at the chain head, complete and in
7999
+ * order, so a job container already running behind the surviving rules is never
8000
+ * left unfiltered even briefly. The superseded rules below it are inert
8001
+ * duplicates.
8002
+ */
8003
+ async function ensureJobSubnetEgressRules(subnet, gateway) {
8004
+ await ensureJobSubnetHostRules(subnet, gateway);
8005
+ const expected = buildIsolationRuleOps([
8006
+ "ip",
8007
+ "saddr",
8008
+ subnet
8009
+ ], gateway).map((tokens) => tokens.join(" "));
8010
+ const chain = await readForwardChain();
8011
+ const present = new Set((chain ?? "").split("\n").map((line) => line.trim().replace(/\s*# handle \d+$/, "")));
8012
+ if (chain !== null && expected.every((rule) => present.has(rule))) return;
8013
+ await addIsolationRules(subnet, gateway, void 0, "saddr");
8014
+ logger$5.info(`Installed default egress drops for ${subnet}`);
8015
+ }
8016
+ /**
8017
+ * Install the subnet-wide host-access rules — what a job container may reach ON
8018
+ * the host, as opposed to through it.
8019
+ *
8020
+ * Keyed on the subnet for the same reason the forward set is: it covers a
8021
+ * container that does not exist yet, so a customer image's `ENTRYPOINT` never
8022
+ * runs in the window before the per-container rules land.
8023
+ *
8024
+ * Idempotent against the chain's own text rather than by remove-then-insert.
8025
+ * Removing first would take the drop away from every running job container for
8026
+ * as long as the re-insert takes, which is the one direction that must not
8027
+ * fail open.
8028
+ */
8029
+ async function ensureJobSubnetHostRules(subnet, gateway) {
8030
+ const hostAccess = resolveJobHostAccess(gateway);
8031
+ const expected = buildHostAccessRuleOps([
8032
+ "ip",
8033
+ "saddr",
8034
+ subnet
8035
+ ], hostAccess).map((tokens) => tokens.join(" "));
8036
+ const chain = await readInputChain();
8037
+ const present = new Set((chain ?? "").split("\n").map((line) => line.trim().replace(/\s*# handle \d+$/, "")));
8038
+ if (chain !== null && expected.every((rule) => present.has(rule))) return;
8039
+ await addHostIsolationRules(subnet, hostAccess, "saddr");
8040
+ logger$5.info(`Installed default host-access rules for ${subnet}`);
8041
+ }
8042
+ /**
8043
+ * Create the `kici-jobs` bridge network, tolerating a concurrent creator.
8044
+ *
8045
+ * Returns the addressing job containers will actually get. That is the
8046
+ * canonical pair whenever this call created the network, and whatever an
8047
+ * already-present network carries otherwise — which is the only thing the drop
8048
+ * set can honestly be keyed on. A network someone else made under this name
8049
+ * with a different subnet would leave every job container outside a set keyed
8050
+ * on the constant, and the pre-start window this whole module exists to close
8051
+ * would reopen for it.
8052
+ */
8053
+ async function ensureJobNetworkExists(docker) {
8054
+ const canonical = {
8055
+ subnet: JOB_NETWORK_SUBNET,
8056
+ gateway: JOB_NETWORK_GATEWAY
8057
+ };
8058
+ const existing = await findJobNetwork(docker);
8059
+ if (existing) return existing;
8060
+ try {
8061
+ await docker.createNetwork({
8062
+ Name: JOB_NETWORK_NAME,
8063
+ Driver: "bridge",
8064
+ IPAM: { Config: [{
8065
+ Subnet: JOB_NETWORK_SUBNET,
8066
+ Gateway: JOB_NETWORK_GATEWAY
8067
+ }] },
8068
+ Labels: { "kici-managed": "true" }
8069
+ });
8070
+ logger$5.info(`Created job network ${JOB_NETWORK_NAME}`);
8071
+ return canonical;
8072
+ } catch (err) {
8073
+ if (err.statusCode === 409) return await findJobNetwork(docker) ?? canonical;
8074
+ throw err;
8075
+ }
8076
+ }
8077
+ /** The present `kici-jobs` network's addressing, or `undefined` when absent. */
8078
+ async function findJobNetwork(docker) {
8079
+ const found = (await docker.listNetworks({ filters: { name: [JOB_NETWORK_NAME] } })).find((n) => n.Name === JOB_NETWORK_NAME);
8080
+ if (!found) return void 0;
8081
+ const config = (found.IPAM?.Config ?? [])[0];
8082
+ return {
8083
+ subnet: config?.Subnet ?? JOB_NETWORK_SUBNET,
8084
+ gateway: config?.Gateway ?? "172.31.0.1"
8085
+ };
8086
+ }
8087
+ /**
8088
+ * Apply the per-container egress rules once the container has an IP.
8089
+ *
8090
+ * This is the per-job layer, not the control: the subnet drop set
8091
+ * {@link ensureJobNetwork} installs is already in the chain and already covers
8092
+ * this container. What a per-container rule set adds is a place to key a future
8093
+ * per-job policy — an allowlist or `denyAll` — on one address rather than on
8094
+ * the whole bridge.
8095
+ *
8096
+ * Returns the container IP the rules are keyed on, so teardown can remove
8097
+ * exactly those rules; `undefined` when no IP could be read, which is logged
8098
+ * rather than thrown — a job whose rules could not be keyed still runs behind
8099
+ * the subnet drops, and the operator sees why.
8100
+ */
8101
+ async function applyJobEgressRules(docker, containerId, policy) {
8102
+ const containerIp = (await docker.getContainer(containerId).inspect()).NetworkSettings?.Networks?.[JOB_NETWORK_NAME]?.IPAddress;
8103
+ if (!containerIp) {
8104
+ logger$5.warn("Could not determine job container IP — no per-container egress rules applied. A container on the kici-jobs bridge stays behind the subnet drops; one that is not on that bridge has unfiltered egress.", { containerId });
8105
+ return;
8106
+ }
8107
+ await addIsolationRules(containerIp, JOB_NETWORK_GATEWAY, policy, "saddr");
8108
+ await addHostIsolationRules(containerIp, resolveJobHostAccess(), "saddr");
8109
+ return containerIp;
8110
+ }
8111
+ /**
8112
+ * Remove the per-container rules at teardown.
8113
+ *
8114
+ * Never throws: a failed cleanup must not turn a finished job into a failed
8115
+ * one. A leaked rule set is keyed on an IP the network will reuse, so it is
8116
+ * reported at `warn` for the operator rather than swallowed.
8117
+ */
8118
+ async function removeJobEgressRules(containerIp) {
8119
+ if (!containerIp) return;
8120
+ try {
8121
+ await removeIsolationRules(containerIp);
8122
+ } catch (err) {
8123
+ logger$5.warn("Failed to remove job-container egress rules", {
8124
+ containerIp,
8125
+ error: toErrorMessage(err)
8126
+ });
8127
+ }
8128
+ }
8129
+ var logger$5, JOB_NETWORK_NAME, JOB_NETWORK_GATEWAY, DNS_PORT;
8130
+ var init_job_network = __esmMin((() => {
8131
+ logger$5 = createLogger({ prefix: "job-network" });
8132
+ JOB_NETWORK_NAME = "kici-jobs";
8133
+ JOB_NETWORK_GATEWAY = "172.31.0.1";
8134
+ DNS_PORT = 53;
8135
+ }));
8136
+ //#endregion
8179
8137
  //#region src/execution/sandbox/container-sandbox.ts
8180
8138
  /**
8181
8139
  * Container execution sandbox implementation.
@@ -8421,6 +8379,7 @@ var init_container_sandbox = __esmMin((() => {
8421
8379
  init_image_preflight();
8422
8380
  init_secret_encryption();
8423
8381
  init_container_hardening();
8382
+ init_job_network();
8424
8383
  logger$4 = createLogger({ prefix: "container-sandbox" });
8425
8384
  MAX_STDERR_LINES = 20;
8426
8385
  ABORT_GRACE_MS = 1e4;
@@ -8449,6 +8408,9 @@ var init_container_sandbox = __esmMin((() => {
8449
8408
  keepFailed;
8450
8409
  jobId;
8451
8410
  hardening;
8411
+ networkIsolation;
8412
+ /** Container IP the applied egress rules are keyed on; unset when none were applied. */
8413
+ egressRuleIp;
8452
8414
  /** Resolved container user (image-user override / grant), applied to createContainer + each exec. */
8453
8415
  resolvedUser;
8454
8416
  /** The running container instance (set during setup). */
@@ -8473,6 +8435,7 @@ var init_container_sandbox = __esmMin((() => {
8473
8435
  this.keepFailed = options.keepFailed ?? false;
8474
8436
  this.jobId = options.jobId ?? `unknown-${Date.now()}`;
8475
8437
  this.hardening = options.hardening;
8438
+ this.networkIsolation = options.networkIsolation ?? true;
8476
8439
  }
8477
8440
  /**
8478
8441
  * Ensure the job's container image is present locally, pulling it on demand
@@ -8532,7 +8495,12 @@ var init_container_sandbox = __esmMin((() => {
8532
8495
  image: this.image,
8533
8496
  workDir: options.workDir
8534
8497
  });
8535
- const hardened = this.hardening ? buildContainerHardening(this.hardening) : {
8498
+ const effectiveNetwork = this.hardening?.grant?.network ?? this.hardening?.networkMode;
8499
+ const egressRulesActive = this.networkIsolation && effectiveNetwork !== "host" && effectiveNetwork !== "none" ? await ensureJobNetwork(this.docker) : false;
8500
+ const hardened = this.hardening ? buildContainerHardening({
8501
+ ...this.hardening,
8502
+ ...egressRulesActive ? { jobNetworkName: JOB_NETWORK_NAME } : {}
8503
+ }) : {
8536
8504
  hostConfig: {},
8537
8505
  user: void 0
8538
8506
  };
@@ -8559,6 +8527,7 @@ var init_container_sandbox = __esmMin((() => {
8559
8527
  }
8560
8528
  });
8561
8529
  await this.container.start();
8530
+ if (egressRulesActive) this.egressRuleIp = await applyJobEgressRules(this.docker, this.container.id, void 0);
8562
8531
  logger$4.info("Sandbox container started", {
8563
8532
  name: this.containerName,
8564
8533
  containerId: this.container.id.slice(0, 12)
@@ -8876,6 +8845,8 @@ var init_container_sandbox = __esmMin((() => {
8876
8845
  } catch {}
8877
8846
  }
8878
8847
  async teardown() {
8848
+ await removeJobEgressRules(this.egressRuleIp);
8849
+ this.egressRuleIp = void 0;
8879
8850
  if (!this.container) {
8880
8851
  await this.reclaimBuiltImage();
8881
8852
  return;
@@ -8965,85 +8936,7 @@ async function fileExists(p) {
8965
8936
  return false;
8966
8937
  }
8967
8938
  }
8968
- /**
8969
- * Build the source / workflow repo pair the round hands to every filter and
8970
- * generator. Mirrors the sandbox's own `setupGlobalWorkflowEnv` construction so
8971
- * a generator's two evaluations see the same identifiers, refs, and shas — only
8972
- * the absolute paths differ, and those are never compared.
8973
- */
8974
- function buildRoundRepos(dispatch, config, workflowDir, sourceDir) {
8975
- return {
8976
- workflowRepo: {
8977
- identifier: config.workflowRepoIdentifier ?? repoIdentifierFromUrl(config.workflowRepoUrl),
8978
- path: workflowDir,
8979
- ref: config.workflowRef,
8980
- sha: config.workflowSha
8981
- },
8982
- sourceRepo: {
8983
- identifier: repoIdentifierFromUrl(dispatch.repoUrl),
8984
- path: sourceDir,
8985
- ref: dispatch.ref,
8986
- sha: dispatch.sha
8987
- }
8988
- };
8989
- }
8990
- /**
8991
- * Resolve the changed-files list a `filter` reads — for a global eval round and
8992
- * for a filter-bearing init job alike.
8993
- *
8994
- * Ground truth is the agent's own source clone; an already-`fetched` list from
8995
- * the orchestrator is a free fast-path. A diff-less event (schedule / tag /
8996
- * manual) resolves to `unavailable`, which makes `ctx.changedFiles` throw
8997
- * rather than read as an empty diff — a `filter` returning false produces no
8998
- * run at all, so a silently-empty diff would suppress the workflow with no
8999
- * artifact anywhere to inspect.
9000
- */
9001
- async function resolveEvalChangedFiles(dispatch, event, sourceDir) {
9002
- const ev = event;
9003
- if (ev.changedFilesStatus === "fetched") return {
9004
- files: ev.changedFiles ?? [],
9005
- status: "fetched"
9006
- };
9007
- return computeChangedFiles(sourceDir, event, dispatch.sourceAuth ?? dispatch.workflowAuth ?? (dispatch.token ? {
9008
- kind: "basic",
9009
- user: "x-access-token",
9010
- secret: dispatch.token
9011
- } : void 0));
9012
- }
9013
- /**
9014
- * Materialize the source tree a non-global workflow's `filter` reads through
9015
- * `ctx.sourceRepo.path`.
9016
- *
9017
- * An init or dynamic-eval job normally restores only `.kici/` from the cached
9018
- * source tarball — enough to import the workflow module, but a directory with no
9019
- * repo in it. A filter that reads a file or shells out against that path would
9020
- * get a confidently wrong answer, and `changedFiles` could not be computed at
9021
- * all, so a filter-bearing job clones the source repo into a sibling directory.
9022
- *
9023
- * When no tarball was attached the job already cloned the whole repo into
9024
- * `workDir`, and that clone is reused rather than duplicated — including the
9025
- * local working-tree case, where there is no repo url and `workDir` IS the tree.
9026
- *
9027
- * A tarball with no repo url is the one combination that cannot be honoured:
9028
- * `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
9029
- * would hand the filter a directory in which every path test answers "absent" —
9030
- * the exact silent lie this function exists to prevent — so it throws instead.
9031
- */
9032
- async function ensureFilterSourceDir(dispatch, workDir) {
9033
- if (!dispatch.sourceTarUrl) return workDir;
9034
- if (!dispatch.repoUrl) throw new Error("Workflow declares a filter, but this job restored its source from the cache with no repo url to clone from — the filter would see an empty tree. Re-run with a source repository configured, or remove the filter.");
9035
- const sourceDir = join(workDir, FILTER_SOURCE_DIRNAME);
9036
- const sourceAuth = dispatch.sourceAuth;
9037
- await gitClone({
9038
- repoUrl: dispatch.repoUrl,
9039
- ref: dispatch.ref,
9040
- sha: dispatch.sha,
9041
- workDir: sourceDir,
9042
- gitAuth: sourceAuth,
9043
- token: sourceAuth ? void 0 : dispatch.token
9044
- });
9045
- return sourceDir;
9046
- }
8939
+ /** Agent-side fallbacks when the orchestrator sends no round budgets. */
9047
8940
  /**
9048
8941
  * Restore deps, materialize the workflow source, and install `.kici/`
9049
8942
  * dependencies for a dynamic-eval job — everything that has to exist before its
@@ -9054,7 +8947,24 @@ async function ensureFilterSourceDir(dispatch, workDir) {
9054
8947
  * handler's `logger.info` call site (which Loki keys off) or parameterize it,
9055
8948
  * and neither is worth it for twenty lines.
9056
8949
  */
9057
- async function materializeEvalWorkspace(dispatch, workDir, log) {
8950
+ /**
8951
+ * Sanitized base environment for a dependency install that runs in the AGENT
8952
+ * process rather than in a child.
8953
+ *
8954
+ * `installDeps` defaults `baseEnv` to `process.env`, which is correct inside the
8955
+ * runner child (already the sanitized job environment) and wrong here: the agent
8956
+ * process holds the orchestrator connection and, before boot scrubs them, the
8957
+ * agent's own credentials. `buildSanitizedEnv` merges its first argument
8958
+ * wholesale at its user-env layer, so it must be `{}` and never `process.env` —
8959
+ * passing `process.env` re-adds every variable the sanitizer just excluded.
8960
+ *
8961
+ * `trustedEnv` is threaded because it is an operator-set agent-launch property a
8962
+ * trusted fleet agent legitimately relies on.
8963
+ */
8964
+ function agentInstallBaseEnv(trustedEnv) {
8965
+ return buildSanitizedEnv({}, { trustedEnv });
8966
+ }
8967
+ async function materializeEvalWorkspace(dispatch, workDir, log, installOpts) {
9058
8968
  if (dispatch.depsUrl) {
9059
8969
  log("Restoring dependencies from cache");
9060
8970
  await restoreDeps(workDir, dispatch.depsUrl, dispatch.depsHash);
@@ -9062,7 +8972,7 @@ async function materializeEvalWorkspace(dispatch, workDir, log) {
9062
8972
  }
9063
8973
  if (dispatch.sourceTarUrl) {
9064
8974
  log("Restoring workflow source from cached tarball");
9065
- await restoreSource(workDir, dispatch.sourceTarUrl);
8975
+ await restoreSource(workDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
9066
8976
  } else {
9067
8977
  log(`Cloning ${dispatch.repoUrl} ref=${dispatch.ref}`);
9068
8978
  const cloneStart = Date.now();
@@ -9082,76 +8992,13 @@ async function materializeEvalWorkspace(dispatch, workDir, log) {
9082
8992
  await installDeps(kiciDir, {
9083
8993
  npmRegistries: dispatch.npmRegistries,
9084
8994
  installEnvSecrets: dispatch.installEnvSecrets,
9085
- jobIdShort: dispatch.jobId.slice(0, 8)
8995
+ jobIdShort: dispatch.jobId.slice(0, 8),
8996
+ baseEnv: installOpts.baseEnv,
8997
+ allowInstallScripts: installOpts.allowInstallScripts
9086
8998
  });
9087
8999
  }
9088
9000
  }
9089
9001
  /**
9090
- * Build the context a non-global workflow's `filter` is evaluated against.
9091
- *
9092
- * `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
9093
- * means — so both carry the same identifier, path, ref, and sha. The zx shell is
9094
- * rooted at the source tree and streams into the evaluating step's log, matching
9095
- * what the global eval round hands its own filters.
9096
- *
9097
- * They are two distinct objects all the same. Being the same repo is a fact
9098
- * about their VALUES, not a licence to hand the author one object under two
9099
- * names: a filter that mutated `ctx.sourceRepo` would silently see
9100
- * `ctx.workflowRepo` change with it, which happens on no other path.
9101
- */
9102
- async function buildInitFilterInput(dispatch, event, workDir, emit) {
9103
- const sourceDir = await ensureFilterSourceDir(dispatch, workDir);
9104
- const diff = await resolveEvalChangedFiles(dispatch, event, sourceDir);
9105
- const repo = {
9106
- identifier: repoIdentifierFromUrl(dispatch.repoUrl),
9107
- path: sourceDir,
9108
- ref: dispatch.ref,
9109
- sha: dispatch.sha
9110
- };
9111
- return {
9112
- sourceRepo: repo,
9113
- workflowRepo: { ...repo },
9114
- changedFiles: diff.files,
9115
- changedFilesStatus: diff.status,
9116
- env: process.env,
9117
- $: await buildEvalShell(sourceDir, emit)
9118
- };
9119
- }
9120
- /**
9121
- * Build the per-invocation zx `$` a global eval round hands to filters and
9122
- * generators, so a `await $\`…\`` inside one is visible in the eval step's log.
9123
- *
9124
- * **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
9125
- * snapshot taken when the shell is built, which is before the round applies the
9126
- * seven `KICI_*` keys — so a filter that shells out (`$\`printenv
9127
- * KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
9128
- * here while the sandbox re-evaluation's ambient `$` resolves `process.env`
9129
- * after `setupGlobalWorkflowEnv` has run and does see them. That is the same
9130
- * two-worlds determinism failure the cwd choice below exists to prevent, one
9131
- * layer down. Passing the live reference reproduces the ambient `$`'s own
9132
- * behaviour, which is what the sandbox uses.
9133
- *
9134
- * `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
9135
- * decrypted secret never leaks into the log.
9136
- *
9137
- * `emit` is a callback rather than the `LogStreamer` itself so the caller can
9138
- * route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
9139
- * flag and `addLine` buffers unconditionally, so a subprocess line arriving
9140
- * after the step was reported would otherwise emit a `log.chunk` for a terminal
9141
- * step. That is the likeliest path for it — an orphaned candidate is usually
9142
- * orphaned *because* it is waiting on a subprocess.
9143
- */
9144
- async function buildEvalShell(cwd, emit) {
9145
- const { $: zx$ } = await import("zx");
9146
- return zx$({
9147
- cwd,
9148
- env: process.env,
9149
- verbose: true,
9150
- quiet: false,
9151
- log: makeStreamingZxLog(emit)
9152
- });
9153
- }
9154
- /**
9155
9002
  * Resolve the absolute path to the compiled workflow-runner.js entry point.
9156
9003
  *
9157
9004
  * The runner is a separate rolldown entry point. Its location depends on the
@@ -9173,6 +9020,25 @@ function resolveRunnerPath() {
9173
9020
  return bundlePath;
9174
9021
  }
9175
9022
  /**
9023
+ * Resolve the compiled eval-runner path, using the same three-location probe as
9024
+ * {@link resolveRunnerPath}.
9025
+ *
9026
+ * The eval child is always forked on the agent's own host from the agent's own
9027
+ * build — an evaluation job never goes through a sandbox backend — so unlike the
9028
+ * step runner it needs no self-contained bundle sibling to mount into a customer
9029
+ * job container.
9030
+ */
9031
+ function resolveEvalRunnerPath() {
9032
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9033
+ const bundlePath = join(__dirname, "eval-runner.js");
9034
+ if (existsSync(bundlePath)) return bundlePath;
9035
+ const originalPath = join(__dirname, "sandbox", "eval-runner.js");
9036
+ if (existsSync(originalPath)) return originalPath;
9037
+ const chunkedPath = join(__dirname, "execution", "sandbox", "eval-runner.js");
9038
+ if (existsSync(chunkedPath)) return chunkedPath;
9039
+ return bundlePath;
9040
+ }
9041
+ /**
9176
9042
  * Derive the self-contained runner bundle path from the resolved runner path.
9177
9043
  *
9178
9044
  * The container backend mounts the runner as a single file into the customer
@@ -9202,14 +9068,6 @@ function determineExecutionMode(jobConfig, agentConfig) {
9202
9068
  return "bare-metal";
9203
9069
  }
9204
9070
  /**
9205
- * Build the result-aware `ctx.needs` for a dynamic eval from its frozen upstream
9206
- * snapshot. Returns undefined for an event-only generator (no snapshot).
9207
- */
9208
- function buildEvalNeedsContext(config) {
9209
- if (!config.resultAware || !config.upstreamSnapshot) return void 0;
9210
- return buildNeedsContext(config.upstreamSnapshot, config.declaredNeeds ?? []);
9211
- }
9212
- /**
9213
9071
  * Resolve a job's workDir and its cleanup.
9214
9072
  *
9215
9073
  * - Default: a fresh `mkdtemp` the agent clones into and removes after the job.
@@ -9236,26 +9094,20 @@ async function resolveJobWorkDir(inPlace, repoUrl) {
9236
9094
  inPlace: false
9237
9095
  };
9238
9096
  }
9239
- var logger$3, DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS, DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS, FILTER_SOURCE_DIRNAME, JobRunner$1;
9097
+ var logger$3, JobRunner$1;
9240
9098
  var init_job_runner = __esmMin((() => {
9241
9099
  init_git_clone();
9242
9100
  init_clone_job_repos();
9243
9101
  init_job_git_credentials();
9244
- init_changed_files();
9245
- init_workflow_loader();
9246
9102
  init_source_packer();
9247
9103
  init_source_restore();
9248
- init_init_runner();
9249
9104
  init_api_intercept();
9250
9105
  init_ensure_init_runner();
9251
9106
  init_payload_source();
9252
9107
  init_s3_payload_source();
9253
- init_timeout_util();
9254
- init_streaming_zx_log();
9255
9108
  init_dynamic_job_serializer();
9256
- init_generator_context();
9257
- init_global_workflow_env();
9258
- init_global_eval_runner();
9109
+ init_eval_fork_runner();
9110
+ init_eval_context();
9259
9111
  init_log_streamer();
9260
9112
  init_build_step();
9261
9113
  init_build_engine();
@@ -9267,9 +9119,6 @@ var init_job_runner = __esmMin((() => {
9267
9119
  init_sandbox();
9268
9120
  init_prometheus();
9269
9121
  logger$3 = createLogger({ prefix: "job-runner" });
9270
- DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS = 12e4;
9271
- DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS = 2e4;
9272
- FILTER_SOURCE_DIRNAME = "__kici_filter_source__";
9273
9122
  JobRunner$1 = class {
9274
9123
  send;
9275
9124
  config;
@@ -9353,8 +9202,8 @@ var init_job_runner = __esmMin((() => {
9353
9202
  * orchestrator relay (unit harnesses, offline local runs).
9354
9203
  *
9355
9204
  * A failure here must not fail the job: without a helper, git falls back to
9356
- * its own mechanisms exactly as it did before this existed. The job simply
9357
- * cannot push.
9205
+ * its own mechanisms exactly as it did before this existed. The job cannot
9206
+ * push.
9358
9207
  */
9359
9208
  async startGitCredentials(jobId, jobConfig) {
9360
9209
  if (!this._sendApiRequest) return void 0;
@@ -9667,6 +9516,7 @@ var init_job_runner = __esmMin((() => {
9667
9516
  }) : void 0,
9668
9517
  onGitGrantRequest: this.jobGitCredentials.get(jobId)?.onGitGrantRequest,
9669
9518
  credentialHelperPath: this.jobGitCredentials.get(jobId)?.helperPath,
9519
+ allowInstallScripts: this.config.allowInstallScripts,
9670
9520
  onCacheRequest: this._requestUserCache ? async (request) => this._requestUserCache(jobId, request) : void 0,
9671
9521
  onProvenanceRequest: this._relayProvenance ? async (request) => this._relayProvenance(jobId, request) : void 0,
9672
9522
  onArtifactRequest: this._requestUserArtifact ? async (request) => this._requestUserArtifact(jobId, request) : void 0,
@@ -9831,7 +9681,7 @@ var init_job_runner = __esmMin((() => {
9831
9681
  this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
9832
9682
  return;
9833
9683
  }
9834
- await this.packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog);
9684
+ await this.packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog, abortController.signal);
9835
9685
  buildLog("Build completed successfully");
9836
9686
  buildStreamer.flush();
9837
9687
  buildStreamer.destroy();
@@ -9914,7 +9764,7 @@ var init_job_runner = __esmMin((() => {
9914
9764
  await fsPromises.mkdir(workDir, { recursive: true });
9915
9765
  } else if (dispatch.sourceTarUrl) {
9916
9766
  initLog("Restoring workflow source from cached tarball");
9917
- await restoreSource(workDir, dispatch.sourceTarUrl);
9767
+ await restoreSource(workDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
9918
9768
  } else {
9919
9769
  initLog(`Cloning ${dispatch.repoUrl} (ref: ${dispatch.ref})`);
9920
9770
  const cloneStart = Date.now();
@@ -9955,13 +9805,16 @@ var init_job_runner = __esmMin((() => {
9955
9805
  await installDeps(kiciDir, {
9956
9806
  npmRegistries: dispatch.npmRegistries,
9957
9807
  installEnvSecrets: dispatch.installEnvSecrets,
9958
- jobIdShort: dispatch.jobId.slice(0, 8)
9808
+ jobIdShort: dispatch.jobId.slice(0, 8),
9809
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
9810
+ allowInstallScripts: this.config.allowInstallScripts
9959
9811
  });
9960
9812
  buildLog("Dependencies installed");
9961
9813
  if (!buildConfig.buildDepsNeeded) return;
9962
9814
  const { tarball, hash } = await packNodeModules(kiciDir);
9963
9815
  const depKey = {
9964
9816
  lockfileHash: buildConfig.lockfileHash,
9817
+ ...buildConfig.siblingsDigest && { siblingsDigest: buildConfig.siblingsDigest },
9965
9818
  platform: os.platform(),
9966
9819
  arch: os.arch()
9967
9820
  };
@@ -9992,16 +9845,28 @@ var init_job_runner = __esmMin((() => {
9992
9845
  * file's expected `contentHash`, pack `.kici/` into a tarball, and upload
9993
9846
  * it to the source cache.
9994
9847
  */
9995
- async packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog) {
9848
+ async packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog, signal) {
9996
9849
  if (!buildConfig.buildSourceNeeded || !buildConfig.contentHash) return;
9997
9850
  const sourceFile = dispatch.jobConfig.source?.file;
9998
9851
  if (!sourceFile) return;
9999
9852
  buildLog(`Verifying workflow source (${sourceFile})...`);
10000
- await runCaptured({ addLine: (line) => buildStreamer.addLine(line) }, () => loadWorkflowSource(workDir, sourceFile, buildConfig.contentHash, buildConfig.resolvedHashFiles));
9853
+ await this.runEvaluation({
9854
+ kind: "build-verify",
9855
+ dispatch,
9856
+ workDir,
9857
+ config: {
9858
+ sourceFile,
9859
+ contentHash: buildConfig.contentHash,
9860
+ resolvedHashFiles: buildConfig.resolvedHashFiles
9861
+ },
9862
+ emit: (line) => buildStreamer.addLine(line),
9863
+ signal
9864
+ });
10001
9865
  buildLog("Packing .kici/ source tarball...");
10002
9866
  const { tarball, hash: sourceTarHash } = await packKiciSource(workDir);
10003
9867
  const sourceKey = {
10004
9868
  contentHash: buildConfig.contentHash,
9869
+ sourceTarDigest: sourceTarHash,
10005
9870
  platform: os.platform(),
10006
9871
  arch: os.arch()
10007
9872
  };
@@ -10061,7 +9926,7 @@ var init_job_runner = __esmMin((() => {
10061
9926
  }
10062
9927
  if (dispatch.sourceTarUrl) {
10063
9928
  log("Restoring workflow source from cached tarball");
10064
- await restoreSource(workflowDir, dispatch.sourceTarUrl);
9929
+ await restoreSource(workflowDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
10065
9930
  }
10066
9931
  const kiciDir = join(workflowDir, ".kici");
10067
9932
  if (!dispatch.depsUrl && await fileExists(join(kiciDir, "package.json"))) {
@@ -10069,7 +9934,9 @@ var init_job_runner = __esmMin((() => {
10069
9934
  await installDeps(kiciDir, {
10070
9935
  npmRegistries: dispatch.npmRegistries,
10071
9936
  installEnvSecrets: dispatch.installEnvSecrets,
10072
- jobIdShort: dispatch.jobId.slice(0, 8)
9937
+ jobIdShort: dispatch.jobId.slice(0, 8),
9938
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
9939
+ allowInstallScripts: this.config.allowInstallScripts
10073
9940
  });
10074
9941
  }
10075
9942
  }
@@ -10111,7 +9978,6 @@ var init_job_runner = __esmMin((() => {
10111
9978
  evalStreamer.destroy();
10112
9979
  streamerClosed = true;
10113
9980
  };
10114
- const evalSink = { addLine: (line) => evalLog(line) };
10115
9981
  this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.running);
10116
9982
  const heartbeatTimer = setInterval(() => {
10117
9983
  this.send({
@@ -10130,28 +9996,14 @@ var init_job_runner = __esmMin((() => {
10130
9996
  return;
10131
9997
  }
10132
9998
  await this.checkoutForGlobalEvalRound(dispatch, config, workflowDir, sourceDir, evalLog);
10133
- const diff = await resolveEvalChangedFiles(dispatch, config.event, sourceDir);
10134
- const evalShell = await buildEvalShell(workDir, evalStreamLine);
10135
- const roundResult = await runCaptured(evalSink, () => runGlobalEvalRound({
10136
- workflowDir,
10137
- sourceDir,
10138
- repos: buildRoundRepos(dispatch, config, workflowDir, sourceDir),
10139
- candidates: config.candidates,
10140
- event: config.event,
10141
- changedFiles: diff.files,
10142
- changedFilesStatus: diff.status,
10143
- roundTimeoutMs: config.roundTimeoutMs ?? DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS,
10144
- candidateTimeoutMs: config.candidateTimeoutMs ?? DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS,
10145
- signal: abortController.signal,
10146
- $: evalShell,
10147
- log: {
10148
- info: (msg) => evalLog(msg),
10149
- warn: (msg) => evalLog(`WARN: ${msg}`),
10150
- error: (msg) => evalLog(`ERROR: ${msg}`),
10151
- debug: (msg) => evalLog(`DEBUG: ${msg}`)
10152
- },
10153
- kici: buildKiciApi(this._sendApiRequest ? withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")))
10154
- }));
9999
+ const roundResult = await this.runEvaluation({
10000
+ kind: "global-eval",
10001
+ dispatch,
10002
+ workDir,
10003
+ config,
10004
+ emit: (line, stream) => stream ? evalStreamLine(line, stream) : evalLog(line),
10005
+ signal: abortController.signal
10006
+ });
10155
10007
  const running = roundResult.candidates.filter((c) => c.run).length;
10156
10008
  const indeterminate = roundResult.candidates.filter((c) => c.indeterminate).length;
10157
10009
  logger$3.info("Global eval round completed", {
@@ -10186,6 +10038,48 @@ var init_job_runner = __esmMin((() => {
10186
10038
  }
10187
10039
  }
10188
10040
  /**
10041
+ * Run one evaluation in the eval child and stream its log onto the job's
10042
+ * synthetic step-0 log.
10043
+ *
10044
+ * Every customer-module load and evaluation goes through here. The calling
10045
+ * handler keeps what is the agent's own work — materializing the workspace,
10046
+ * installing dependencies, packing and uploading a tarball, reporting status —
10047
+ * and hands the child only the evaluation itself, in a process whose
10048
+ * environment carries no agent credential.
10049
+ */
10050
+ runEvaluation(args) {
10051
+ const { jobId } = args.dispatch;
10052
+ return runEvalChild({
10053
+ evalRunnerPath: resolveEvalRunnerPath(),
10054
+ request: {
10055
+ kind: args.kind,
10056
+ workDir: args.workDir,
10057
+ config: args.config,
10058
+ dispatch: {
10059
+ runId: args.dispatch.runId,
10060
+ jobId: args.dispatch.jobId,
10061
+ repoUrl: args.dispatch.repoUrl,
10062
+ ref: args.dispatch.ref,
10063
+ sha: args.dispatch.sha,
10064
+ token: args.dispatch.token,
10065
+ sourceAuth: args.dispatch.sourceAuth,
10066
+ workflowAuth: args.dispatch.workflowAuth,
10067
+ sourceTarUrl: args.dispatch.sourceTarUrl
10068
+ }
10069
+ },
10070
+ onLogLine: args.emit,
10071
+ ...this._sendApiRequest ? { onApiRequest: withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) } : {},
10072
+ trustedEnv: this.config.trustedEnv,
10073
+ signal: args.signal
10074
+ }).catch((err) => {
10075
+ logger$3.debug("Evaluation child reported a failure", {
10076
+ jobId,
10077
+ error: toErrorMessage(err)
10078
+ });
10079
+ throw err;
10080
+ });
10081
+ }
10082
+ /**
10189
10083
  * Handle an init-only job.
10190
10084
  *
10191
10085
  * Init jobs evaluate dynamic functions (environment, env, concurrencyGroup)
@@ -10220,7 +10114,6 @@ var init_job_runner = __esmMin((() => {
10220
10114
  await initStreamer.flush();
10221
10115
  initStreamer.destroy();
10222
10116
  };
10223
- const initSink = { addLine: (line) => initLog(line) };
10224
10117
  this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.running);
10225
10118
  const heartbeatTimer = setInterval(() => {
10226
10119
  this.send({
@@ -10254,21 +10147,18 @@ var init_job_runner = __esmMin((() => {
10254
10147
  await installDeps(kiciDir, {
10255
10148
  npmRegistries: dispatch.npmRegistries,
10256
10149
  installEnvSecrets: dispatch.installEnvSecrets,
10257
- jobIdShort: dispatch.jobId.slice(0, 8)
10150
+ jobIdShort: dispatch.jobId.slice(0, 8),
10151
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
10152
+ allowInstallScripts: this.config.allowInstallScripts
10258
10153
  });
10259
10154
  }
10260
- const filterInput = config.hasFilter ? await buildInitFilterInput(dispatch, config.event, workDir, initStreamLine) : void 0;
10261
- const initResult = await runCaptured(initSink, async () => {
10262
- const { module } = await loadWorkflowSource(workDir, config.source, config.contentHash, config.resolvedHashFiles);
10263
- const workflow = extractWorkflow(module, config.workflowName);
10264
- initLog(`Evaluating dynamic fields for job '${config.targetJobName}' (env=${config.dynamicEnv} context=${config.dynamicContext} concurrencyGroup=${config.dynamicConcurrencyGroup} matrix=${config.dynamicMatrix ?? false} filter=${config.hasFilter ?? false})`);
10265
- return evaluateDynamicFields(workflow, config.targetJobName, config.event, {
10266
- dynamicContext: config.dynamicContext,
10267
- dynamicEnv: config.dynamicEnv,
10268
- dynamicConcurrencyGroup: config.dynamicConcurrencyGroup,
10269
- dynamicMatrix: config.dynamicMatrix ?? false,
10270
- hasFilter: config.hasFilter ?? false
10271
- }, config.timeoutMs, filterInput);
10155
+ const initResult = await this.runEvaluation({
10156
+ kind: "init",
10157
+ dispatch,
10158
+ workDir,
10159
+ config,
10160
+ emit: (line, stream) => stream ? initStreamLine(line, stream) : initLog(line),
10161
+ signal: abortController.signal
10272
10162
  });
10273
10163
  logger$3.info("Init job completed successfully", {
10274
10164
  jobId,
@@ -10312,7 +10202,6 @@ var init_job_runner = __esmMin((() => {
10312
10202
  async handleDynamicJobFn(dispatch, workDir, abortController) {
10313
10203
  const { runId, jobId, jobConfig } = dispatch;
10314
10204
  const config = jobConfig;
10315
- const timeoutMs = config.timeoutMs ?? 12e4;
10316
10205
  logger$3.info("Starting DynamicJobFn evaluation", {
10317
10206
  jobId,
10318
10207
  workflowName: config.workflowName,
@@ -10336,47 +10225,17 @@ var init_job_runner = __esmMin((() => {
10336
10225
  this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
10337
10226
  return;
10338
10227
  }
10339
- await materializeEvalWorkspace(dispatch, workDir, evalLog);
10340
- const scopedDollar = await buildEvalShell(workDir, (line, stream) => evalStreamer.addLine(line, stream));
10341
- const evalSink = { addLine: (line) => evalStreamer.addLine(line) };
10342
- const filterInput = config.hasFilter ? await buildInitFilterInput(dispatch, config.event, workDir, (line, stream) => evalStreamer.addLine(line, stream)) : void 0;
10343
- const dynamicJobLogger = {
10344
- info: (msg, ..._args) => evalLog(msg),
10345
- warn: (msg, ..._args) => evalLog(`WARN: ${msg}`),
10346
- error: (msg, ..._args) => evalLog(`ERROR: ${msg}`),
10347
- debug: (msg, ..._args) => evalLog(`DEBUG: ${msg}`)
10348
- };
10349
- const kici = buildKiciApi(this._sendApiRequest ? withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")));
10350
- const lockJobs = await runCaptured(evalSink, async () => {
10351
- const { module } = await loadWorkflowSource(workDir, config.source.file, config.contentHash, config.resolvedHashFiles);
10352
- evalLog("Workflow loaded");
10353
- const { extractDynamicJobFn } = await Promise.resolve().then(() => (init_workflow_loader(), workflow_loader_exports));
10354
- const workflow = extractWorkflow(module, config.workflowName);
10355
- if (config.hasFilter) {
10356
- if (!await evaluateWorkflowFilter(workflow, config.event, filterInput, timeoutMs)) {
10357
- evalLog(`Workflow filter returned false — '${config.workflowName}' does not apply to this event, so its generator is not run and no jobs are generated`);
10358
- return [];
10359
- }
10360
- }
10361
- const dynamicFn = extractDynamicJobFn(workflow, config.source.index);
10362
- evalLog(`Evaluating DynamicJobFn (index ${config.source.index}, timeout ${timeoutMs}ms)`);
10363
- const needs = buildEvalNeedsContext(config);
10364
- const context = buildGeneratorContext({
10365
- workflowName: config.workflowName,
10366
- event: config.event,
10367
- env: process.env,
10368
- ...needs && { needs },
10369
- $: scopedDollar,
10370
- log: dynamicJobLogger,
10371
- kici
10372
- });
10373
- return serializeJobsToLock(await withTimeout(() => dynamicFn(context), timeoutMs, `DynamicJobFn index ${config.source.index} in workflow '${config.workflowName}'`), {
10374
- event: config.event,
10375
- $: scopedDollar,
10376
- log: dynamicJobLogger,
10377
- env: process.env,
10378
- workflowName: config.workflowName
10379
- });
10228
+ await materializeEvalWorkspace(dispatch, workDir, evalLog, {
10229
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
10230
+ allowInstallScripts: this.config.allowInstallScripts
10231
+ });
10232
+ const lockJobs = await this.runEvaluation({
10233
+ kind: "dynamic-job",
10234
+ dispatch,
10235
+ workDir,
10236
+ config,
10237
+ emit: (line, stream) => evalStreamer.addLine(line, stream),
10238
+ signal: abortController.signal
10380
10239
  });
10381
10240
  logger$3.info("DynamicJobFn evaluation completed", {
10382
10241
  jobId,
@@ -10478,6 +10337,7 @@ var init_job_runner = __esmMin((() => {
10478
10337
  networkMode: this.config.sandboxNetwork === "host" ? "host" : "default",
10479
10338
  grant: opts.jobConfig.sandboxGrant
10480
10339
  },
10340
+ networkIsolation: this.config.sandboxNetworkIsolation,
10481
10341
  ...opts.registryAuth ? { registryAuth: opts.registryAuth } : {},
10482
10342
  ...opts.builtImage ? { buildTag: opts.builtImage } : {},
10483
10343
  ...this.config.runtimeNodeSource ? { runtimeNodePath: this.config.runtimeNodeSource } : {},
@@ -10493,7 +10353,8 @@ var init_job_runner = __esmMin((() => {
10493
10353
  env: opts.env,
10494
10354
  sandbox: this.config.sandbox,
10495
10355
  sandboxNetwork: this.config.sandboxNetwork,
10496
- orphanCleanup: this.config.orphanCleanup
10356
+ orphanCleanup: this.config.orphanCleanup,
10357
+ ...this.config.runnerUser ? { runnerUser: this.config.runnerUser } : {}
10497
10358
  });
10498
10359
  }
10499
10360
  }
@@ -10797,16 +10658,15 @@ var init_between_jobs_controller = __esmMin((() => {
10797
10658
  * - SIGTERM/SIGINT: 10s grace for running jobs, then force-kill
10798
10659
  * - SIGUSR1: Drain mode (stop accepting, finish current, exit)
10799
10660
  */
10800
- init_console_capture();
10801
10661
  init_npm_resolver();
10802
- const AGENT_VERSION = "0.6.1";
10803
- const BUILD_COMMIT = "d32193b6b";
10804
- const SDK_VERSION = "0.6.1";
10805
- const SDK_BUNDLE_HASH = "22faf0da45de7c243ce87f80fd33ee51b1df52809fde457b16bb821678f65eb3";
10806
- const SHARED_VERSION = "0.6.1";
10807
- const SHARED_BUNDLE_HASH = "a79be949815735b9e36eecc716f7eb07aa36c7bffba62e798e7e19f31a474eff";
10808
- const ENGINE_VERSION = "0.6.1";
10809
- const ENGINE_BUNDLE_HASH = "e49533b19f122155b3a78d95d105f97da259c2a45dee579578324b0702c91708";
10662
+ const AGENT_VERSION = "0.8.0";
10663
+ const BUILD_COMMIT = "57712173f";
10664
+ const SDK_VERSION = "0.8.0";
10665
+ const SDK_BUNDLE_HASH = "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
10666
+ const SHARED_VERSION = "0.8.0";
10667
+ const SHARED_BUNDLE_HASH = "c1c70e41a1ec14fd7cf3a6047251e9aa11258a21e3694ea89a50e8bf14b99f9d";
10668
+ const ENGINE_VERSION = "0.8.0";
10669
+ const ENGINE_BUNDLE_HASH = "b26b2f23059c2ceb06a65feb2dcfe2d8c8be45ccf66622823ffedf742d2d7599";
10810
10670
  initTelemetry({
10811
10671
  serviceName: "kici-agent",
10812
10672
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
@@ -10845,7 +10705,11 @@ async function serializeAgentMetrics() {
10845
10705
  installConsoleCapture();
10846
10706
  await guardStartup(logger$1, async () => {
10847
10707
  const config = loadConfig();
10708
+ const scrubbedEnvVars = scrubCredentialEnv();
10709
+ const exposedPosture = describeExposedPosture(config);
10710
+ if (exposedPosture) logger$1.warn(exposedPosture);
10848
10711
  logger$1.info("Agent starting", {
10712
+ scrubbedEnvVars,
10849
10713
  agentId: config.agentId,
10850
10714
  orchestratorUrl: config.orchestratorUrl,
10851
10715
  labels: config.labels,