@kici-dev/agent 0.6.1 → 0.7.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 +1527 -1665
  28. package/dist/workflow-runner-bundle.js +1105 -184
  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,7 +1913,7 @@ 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
  });
@@ -1839,7 +1922,7 @@ var OrchestratorClient = class OrchestratorClient {
1839
1922
  return;
1840
1923
  }
1841
1924
  if (heartbeatSchema.safeParse(raw).success) return;
1842
- logger$14.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1925
+ logger$16.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1843
1926
  }
1844
1927
  flushBuffer() {
1845
1928
  const events = this.eventBuffer.flush();
@@ -1853,11 +1936,11 @@ var OrchestratorClient = class OrchestratorClient {
1853
1936
  }
1854
1937
  this.disconnectedAt = null;
1855
1938
  if (events.length > 0) {
1856
- logger$14.info("Flushing event buffer", { count: events.length });
1939
+ logger$16.info("Flushing event buffer", { count: events.length });
1857
1940
  for (const msg of events) this.sendDirect(msg);
1858
1941
  }
1859
1942
  if (logLines.length > 0) {
1860
- logger$14.info("Flushing log buffer", { count: logLines.length });
1943
+ logger$16.info("Flushing log buffer", { count: logLines.length });
1861
1944
  for (let i = 0; i < logLines.length; i += OrchestratorClient.LOG_BATCH_SIZE) {
1862
1945
  const batch = logLines.slice(i, i + OrchestratorClient.LOG_BATCH_SIZE);
1863
1946
  this.sendAgentLogMessage(batch);
@@ -1910,14 +1993,14 @@ var OrchestratorClient = class OrchestratorClient {
1910
1993
  */
1911
1994
  blockMmdsAccess() {
1912
1995
  if (process.getuid?.() !== 0) {
1913
- logger$14.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1996
+ logger$16.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1914
1997
  return;
1915
1998
  }
1916
1999
  try {
1917
2000
  execSync("iptables -A OUTPUT -d 169.254.169.254 -j DROP", { timeout: 5e3 });
1918
- logger$14.info("MMDS access blocked via iptables");
2001
+ logger$16.info("MMDS access blocked via iptables");
1919
2002
  } catch (err) {
1920
- logger$14.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
2003
+ logger$16.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
1921
2004
  }
1922
2005
  }
1923
2006
  /**
@@ -1931,7 +2014,7 @@ var OrchestratorClient = class OrchestratorClient {
1931
2014
  messageId: `config-ack-${agentId}-${Date.now()}`,
1932
2015
  agentId
1933
2016
  }));
1934
- logger$14.info("Config ACK sent to orchestrator", { agentId });
2017
+ logger$16.info("Config ACK sent to orchestrator", { agentId });
1935
2018
  }
1936
2019
  }
1937
2020
  startHeartbeat() {
@@ -1998,12 +2081,12 @@ var OrchestratorClient = class OrchestratorClient {
1998
2081
  scheduleReconnect() {
1999
2082
  this.cancelReconnect();
2000
2083
  if (this.authFailed) {
2001
- logger$14.error("Not reconnecting: authentication permanently failed");
2084
+ logger$16.error("Not reconnecting: authentication permanently failed");
2002
2085
  return;
2003
2086
  }
2004
2087
  const delay = this.getReconnectDelay();
2005
2088
  this.reconnectAttempts++;
2006
- logger$14.info("Scheduling reconnect", {
2089
+ logger$16.info("Scheduling reconnect", {
2007
2090
  attempt: this.reconnectAttempts,
2008
2091
  delayMs: Math.round(delay)
2009
2092
  });
@@ -2022,6 +2105,32 @@ var OrchestratorClient = class OrchestratorClient {
2022
2105
  //#endregion
2023
2106
  //#region src/execution/console-capture.ts
2024
2107
  /**
2108
+ * AsyncLocalStorage holding the active CaptureSink.
2109
+ *
2110
+ * When a sink is active, the patched console.* methods route formatted lines
2111
+ * to the sink. When no sink is active, the patched methods fall through to
2112
+ * the original console methods.
2113
+ *
2114
+ * Nested runCaptured() shadows the outer sink for the duration of the inner
2115
+ * scope; ALS restores the outer sink when the inner scope exits.
2116
+ */
2117
+ const consoleCapture = new AsyncLocalStorage();
2118
+ const METHODS = [
2119
+ "log",
2120
+ "error",
2121
+ "warn",
2122
+ "info",
2123
+ "debug"
2124
+ ];
2125
+ const originals = {
2126
+ log: console.log.bind(console),
2127
+ error: console.error.bind(console),
2128
+ warn: console.warn.bind(console),
2129
+ info: console.info.bind(console),
2130
+ debug: console.debug.bind(console)
2131
+ };
2132
+ let installed = false;
2133
+ /**
2025
2134
  * Install monkey-patches on console.log / error / warn / info / debug.
2026
2135
  *
2027
2136
  * Idempotent: subsequent calls are no-ops.
@@ -2046,56 +2155,18 @@ function installConsoleCapture() {
2046
2155
  for (const line of lines) if (line) sink.addLine(line);
2047
2156
  };
2048
2157
  }
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
2158
  //#endregion
2087
2159
  //#region src/routes/health.ts
2088
- init_console_capture();
2089
2160
  function safe(name, fallback = "unknown") {
2090
2161
  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";
2162
+ case "version": return "0.7.0";
2163
+ case "buildCommit": return "31843a21c";
2164
+ case "sdkVersion": return "0.7.0";
2165
+ case "sdkBundleHash": return "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
2166
+ case "sharedVersion": return "0.7.0";
2096
2167
  case "sharedBundleHash": return "a79be949815735b9e36eecc716f7eb07aa36c7bffba62e798e7e19f31a474eff";
2097
- case "engineVersion": return "0.6.1";
2098
- case "engineBundleHash": return "e49533b19f122155b3a78d95d105f97da259c2a45dee579578324b0702c91708";
2168
+ case "engineVersion": return "0.7.0";
2169
+ case "engineBundleHash": return "380f6edf7e0e5cfe988fa0f98512de6e6864f4f2a7a8d07f67e2ab84ed637d19";
2099
2170
  default: return fallback;
2100
2171
  }
2101
2172
  }
@@ -2380,7 +2451,7 @@ async function gcStaleAgentTmpDirs(base = kiciTmpBase()) {
2380
2451
  * spawn fails and the caller clears the orchestrator's reboot-pending flag and
2381
2452
  * surfaces the error — the deadline sweep is the backstop.
2382
2453
  */
2383
- const logger$13 = createLogger({ prefix: "reboot" });
2454
+ const logger$15 = createLogger({ prefix: "reboot" });
2384
2455
  /** The OS reboot primitive for a Node platform string. */
2385
2456
  function rebootCommandFor(platform) {
2386
2457
  switch (platform) {
@@ -2418,7 +2489,7 @@ function issueReboot(platform = process.platform) {
2418
2489
  stdio: "ignore"
2419
2490
  });
2420
2491
  child.on("error", (err) => {
2421
- logger$13.error("Reboot command failed to spawn", {
2492
+ logger$15.error("Reboot command failed to spawn", {
2422
2493
  cmd,
2423
2494
  args,
2424
2495
  error: String(err)
@@ -2426,14 +2497,14 @@ function issueReboot(platform = process.platform) {
2426
2497
  reject(err);
2427
2498
  });
2428
2499
  child.on("exit", (code) => {
2429
- if (code !== 0 && code !== null) logger$13.warn("Reboot command exited non-zero (privilege denied?)", {
2500
+ if (code !== 0 && code !== null) logger$15.warn("Reboot command exited non-zero (privilege denied?)", {
2430
2501
  cmd,
2431
2502
  args,
2432
2503
  code
2433
2504
  });
2434
2505
  });
2435
2506
  child.unref();
2436
- logger$13.info("Issued host reboot", {
2507
+ logger$15.info("Issued host reboot", {
2437
2508
  cmd,
2438
2509
  args
2439
2510
  });
@@ -3139,7 +3210,8 @@ async function startJobGitCredentials(args) {
3139
3210
  helperPath: host.helperPath,
3140
3211
  onGitGrantRequest,
3141
3212
  withRef: (params) => {
3142
- const { credential, ...rest } = params;
3213
+ const { credential, ref: incoming, ...rest } = params;
3214
+ if (incoming !== void 0) throw new Error("git credential refs are resolved from the job's lock by name; pass 'credential', not 'ref'");
3143
3215
  const ref = refFor(typeof credential === "string" ? credential : void 0);
3144
3216
  return ref ? {
3145
3217
  ...rest,
@@ -3159,573 +3231,133 @@ var init_job_git_credentials = __esmMin((() => {
3159
3231
  init_credential_helper_host();
3160
3232
  }));
3161
3233
  //#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
3234
+ //#region src/execution/source-packer.ts
3235
+ /**
3236
+ * `.kici/` source tarball creation for build agents.
3237
+ *
3238
+ * After cloning a customer repo, packs the `.kici/` directory (excluding
3239
+ * `node_modules/` that lives in its own cached tarball per `dep-packer.ts`)
3240
+ * into a deterministic gzip tarball. The tarball bytes are hashed for
3241
+ * integrity verification on the execution-job side.
3242
+ *
3243
+ * The returned hash is the tarball's OWN digest. The orchestrator stores the
3244
+ * object under it and the restoring agent verifies the downloaded bytes against
3245
+ * it, the same contract the dependency tarball has always had; a separate
3246
+ * pointer resolves the workflow `contentHash` to it.
3247
+ */
3248
+ async function packKiciSource(workDir) {
3249
+ const kiciDir = join(workDir, ".kici");
3250
+ if (!existsSync(kiciDir)) throw new Error(`.kici/ not found at ${kiciDir}`);
3251
+ logger$14.info("Packing .kici/ source tarball", { dir: workDir });
3252
+ const startTime = Date.now();
3253
+ const stream = c({
3254
+ gzip: true,
3255
+ cwd: workDir,
3256
+ portable: true,
3257
+ filter: (filePath) => !filePath.startsWith(KICI_SOURCE_EXCLUDED_PREFIX)
3258
+ }, [".kici"]);
3259
+ const chunks = [];
3260
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
3261
+ const tarball = Buffer.concat(chunks);
3262
+ const hash = sha256(tarball);
3263
+ const sizeKB = (tarball.length / 1024).toFixed(2);
3264
+ const durationMs = Date.now() - startTime;
3265
+ logger$14.info(".kici/ source packed", {
3266
+ sizeKB,
3267
+ hash: hash.slice(0, 12),
3268
+ durationMs
3174
3269
  });
3175
3270
  return {
3176
- args: [],
3177
- env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
3178
- cleanup: () => sshSetup.cleanup()
3271
+ tarball,
3272
+ hash
3179
3273
  };
3180
3274
  }
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
- }
3275
+ var logger$14;
3276
+ var init_source_packer = __esmMin((() => {
3277
+ logger$14 = createLogger({ prefix: "source-packer" });
3278
+ }));
3279
+ //#endregion
3280
+ //#region src/execution/dep-restore.ts
3281
+ /**
3282
+ * Dependency restoration from cached tarballs.
3283
+ *
3284
+ * Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
3285
+ * and extracts to .kici/node_modules/ in the work directory.
3286
+ *
3287
+ * HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
3288
+ * gunzip -> tar extract) to avoid buffering entire tarballs in memory.
3289
+ * file:// URLs use a buffer-based approach (local, no streaming benefit).
3290
+ *
3291
+ * Streaming downloads have a 5-minute timeout and up to 2 retries.
3292
+ */
3293
+ /**
3294
+ * Compute SHA-256 hash of a buffer.
3295
+ */
3296
+ function computeHash(data) {
3297
+ return sha256(data);
3208
3298
  }
3209
- function parseNameOnly(out) {
3210
- return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
3299
+ /**
3300
+ * Extract a gzip tarball from a buffer into the target directory.
3301
+ * Used for file:// URLs where streaming provides no benefit.
3302
+ */
3303
+ async function extractTarball(data, targetDir) {
3304
+ await mkdir(targetDir, { recursive: true });
3305
+ const readable = Readable.from(data);
3306
+ await new Promise((resolve, reject) => {
3307
+ readable.pipe(x({
3308
+ cwd: targetDir,
3309
+ gzip: true
3310
+ })).on("finish", resolve).on("error", reject);
3311
+ });
3211
3312
  }
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;
3313
+ /**
3314
+ * Stream download and extract an HTTP/HTTPS tarball.
3315
+ *
3316
+ * Computes SHA-256 hash on the fly via a Transform stream.
3317
+ * Returns the computed hash of the compressed tarball data.
3318
+ */
3319
+ async function streamFetchAndExtract(url, targetDir) {
3320
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
3321
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3322
+ if (!response.body) throw new Error("No response body");
3323
+ const nodeStream = Readable.fromWeb(response.body);
3324
+ const hash = createHash("sha256");
3325
+ const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
3326
+ hash.update(chunk);
3327
+ callback(null, chunk);
3328
+ } });
3329
+ await mkdir(targetDir, { recursive: true });
3330
+ await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
3331
+ return hash.digest("hex");
3245
3332
  }
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
- };
3333
+ /**
3334
+ * Extract the dep tarball into a per-attempt scratch dir so retries never race
3335
+ * with still-draining I/O from a previous failed attempt.
3336
+ *
3337
+ * When `pipeline()` rejects on a network error or AbortSignal timeout, the
3338
+ * underlying `tar.x` continues flushing pending file writes for an unbounded
3339
+ * window after the promise settles — `pipeline` does not block on async
3340
+ * filesystem side effects. If the next retry then runs `rm -rf` on the same
3341
+ * `node_modules/`, the walk races with those writes and `rmdir` fails with
3342
+ * ENOTEMPTY (new files keep appearing under a directory we just emptied).
3343
+ *
3344
+ * We sidestep the race entirely by extracting each attempt into a unique
3345
+ * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
3346
+ * draining writes are harmless — the next attempt does not touch them. On
3347
+ * success `moveScratchIntoRepo` renames the extracted entries into place
3348
+ * (atomic on the same filesystem), then best-effort cleans the scratch dir.
3349
+ *
3350
+ * Scratch dirs land inside the customer's cloned working tree, so the clone
3351
+ * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
3352
+ * keep them out of `git status` for any workflow step that shells out to git.
3353
+ * See `excludeScratchFromGit`.
3354
+ */
3355
+ async function extractIntoScratch(url, kiciDir, attempt) {
3356
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
3357
+ await mkdir(scratchDir, { recursive: true });
3253
3358
  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)
3359
+ scratchDir,
3360
+ hash: await streamFetchAndExtract(url, scratchDir)
3729
3361
  };
3730
3362
  }
3731
3363
  /**
@@ -3768,7 +3400,7 @@ async function excludeScratchFromGit(repoWorkDir) {
3768
3400
  const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
3769
3401
  await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
3770
3402
  } catch (err) {
3771
- logger$11.warn("Failed to register scratch dir glob in .git/info/exclude", {
3403
+ logger$13.warn("Failed to register scratch dir glob in .git/info/exclude", {
3772
3404
  excludePath,
3773
3405
  error: err instanceof Error ? err.message : String(err)
3774
3406
  });
@@ -3829,7 +3461,7 @@ async function cleanupScratch(scratchDir) {
3829
3461
  force: true
3830
3462
  });
3831
3463
  } catch (cleanupErr) {
3832
- logger$11.warn("Scratch dir cleanup failed (orphan left behind)", {
3464
+ logger$13.warn("Scratch dir cleanup failed (orphan left behind)", {
3833
3465
  scratchDir,
3834
3466
  error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
3835
3467
  });
@@ -3854,7 +3486,7 @@ async function cleanupScratch(scratchDir) {
3854
3486
  */
3855
3487
  async function restoreDeps(workDir, depsUrl, depsHash) {
3856
3488
  depsUrl = resolveOrchestratorUrl(depsUrl);
3857
- logger$11.info("Downloading dependency tarball", { url: depsUrl });
3489
+ logger$13.info("Downloading dependency tarball", { url: depsUrl });
3858
3490
  const kiciDir = join(workDir, ".kici");
3859
3491
  if (depsUrl.startsWith("file://")) {
3860
3492
  const localPath = fileURLToPath(depsUrl);
@@ -3868,7 +3500,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3868
3500
  await moveScratchIntoRepo(scratchDir, workDir);
3869
3501
  await cleanupScratch(scratchDir);
3870
3502
  const sizeMB = (data.length / 1048576).toFixed(2);
3871
- logger$11.info("Dependencies restored from cache (file)", {
3503
+ logger$13.info("Dependencies restored from cache (file)", {
3872
3504
  sizeMB,
3873
3505
  targetDir: workDir
3874
3506
  });
@@ -3877,7 +3509,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3877
3509
  if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
3878
3510
  let lastError;
3879
3511
  for (let attempt = 0; attempt <= 2; attempt++) {
3880
- if (attempt > 0) logger$11.warn("Retrying dep tarball download", {
3512
+ if (attempt > 0) logger$13.warn("Retrying dep tarball download", {
3881
3513
  attempt,
3882
3514
  url: depsUrl
3883
3515
  });
@@ -3886,11 +3518,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3886
3518
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
3887
3519
  await moveScratchIntoRepo(scratchDir, workDir);
3888
3520
  await cleanupScratch(scratchDir);
3889
- logger$11.info("Dependencies restored from cache (stream)", { targetDir: workDir });
3521
+ logger$13.info("Dependencies restored from cache (stream)", { targetDir: workDir });
3890
3522
  return;
3891
3523
  } catch (err) {
3892
3524
  lastError = err instanceof Error ? err : new Error(String(err));
3893
- logger$11.warn("Dep tarball download failed", {
3525
+ logger$13.warn("Dep tarball download failed", {
3894
3526
  attempt,
3895
3527
  error: lastError.message
3896
3528
  });
@@ -3898,9 +3530,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
3898
3530
  }
3899
3531
  throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
3900
3532
  }
3901
- var logger$11, DOWNLOAD_TIMEOUT_MS$1, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
3533
+ var logger$13, DOWNLOAD_TIMEOUT_MS$1, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
3902
3534
  var init_dep_restore = __esmMin((() => {
3903
- logger$11 = createLogger({ prefix: "dep-restore" });
3535
+ logger$13 = createLogger({ prefix: "dep-restore" });
3904
3536
  DOWNLOAD_TIMEOUT_MS$1 = 3e5;
3905
3537
  SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
3906
3538
  SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
@@ -4013,7 +3645,7 @@ async function uploadToPresignedUrl(url, data, opts) {
4013
3645
  for (let attempt = 0; attempt <= 2; attempt++) {
4014
3646
  if (attempt > 0) {
4015
3647
  const delayMs = baseDelayMs * 2 ** (attempt - 1);
4016
- logger$10.warn("Retrying pre-signed upload", {
3648
+ logger$12.warn("Retrying pre-signed upload", {
4017
3649
  attempt,
4018
3650
  delayMs,
4019
3651
  error: lastError?.message
@@ -4030,10 +3662,10 @@ async function uploadToPresignedUrl(url, data, opts) {
4030
3662
  }
4031
3663
  throw new Error(`Pre-signed upload failed after 3 attempts: ${lastError?.message}`);
4032
3664
  }
4033
- var logger$10, DOWNLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
3665
+ var logger$12, DOWNLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
4034
3666
  var init_download = __esmMin((() => {
4035
3667
  init_dep_restore();
4036
- logger$10 = createLogger({ prefix: "agent:download" });
3668
+ logger$12 = createLogger({ prefix: "agent:download" });
4037
3669
  DOWNLOAD_TIMEOUT_MS = 3e5;
4038
3670
  UPLOAD_TIMEOUT_MS = 3e5;
4039
3671
  UPLOAD_RETRY_BASE_DELAY_MS = 500;
@@ -4052,22 +3684,30 @@ var init_download = __esmMin((() => {
4052
3684
  * `.kici/` source tarball restoration for execution agents.
4053
3685
  *
4054
3686
  * Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
4055
- * and extracts it into `workDir/` so the workflow entry point becomes
3687
+ * and installs it at `workDir/.kici` so the workflow entry point becomes
4056
3688
  * importable. Mirrors the shape of `dep-restore.ts` but without the streaming
4057
3689
  * optimization — source tarballs are tiny (kilobytes, not the hundreds of
4058
3690
  * megabytes a `node_modules/` tarball carries).
4059
3691
  *
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).
3692
+ * Two properties this path is responsible for, both of which it previously
3693
+ * lacked:
3694
+ *
3695
+ * **Verification.** `dispatch.sourceTarDigest` is the SHA-256 of the tarball's
3696
+ * own bytes, so the download is checked before anything is extracted — the same
3697
+ * contract `restoreDeps` has always had via `depsHash`. The older
3698
+ * `dispatch.sourceTarHash` field carries the workflow `contentHash` instead, so
3699
+ * it never could serve this purpose; it stays on the wire for older peers and
3700
+ * is deliberately not used as a verification input here. When no digest is
3701
+ * dispatched (an older orchestrator, or a source that did not come from the
3702
+ * content-addressed cache) the restore proceeds unverified rather than failing,
3703
+ * so a mixed-version rollout still runs.
3704
+ *
3705
+ * **Replacement, not overlay.** Extraction lands in a scratch directory and the
3706
+ * result REPLACES `workDir/.kici` wholesale, save for `node_modules/` — the one
3707
+ * directory the tarball deliberately omits, which the deps restore has already
3708
+ * written by the time this runs. Extracting over the existing tree left any file
3709
+ * the tarball no longer carries in place, so a helper the author deleted
3710
+ * survived every warm-cache run and kept being imported.
4071
3711
  */
4072
3712
  async function extractSourceTarball(data, targetDir) {
4073
3713
  await mkdir(targetDir, { recursive: true });
@@ -4079,9 +3719,16 @@ async function extractSourceTarball(data, targetDir) {
4079
3719
  })).on("finish", resolve).on("error", reject);
4080
3720
  });
4081
3721
  }
4082
- async function restoreSource(workDir, sourceTarUrl) {
3722
+ /**
3723
+ * Download, verify, and install the `.kici/` source tree.
3724
+ *
3725
+ * @param workDir - Root of the cloned repository; `.kici` is replaced under it
3726
+ * @param sourceTarUrl - `http://`, `https://`, or `file://` URL to the tarball
3727
+ * @param sourceTarDigest - Expected SHA-256 of the tarball bytes, when known
3728
+ */
3729
+ async function restoreSource(workDir, sourceTarUrl, sourceTarDigest) {
4083
3730
  sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
4084
- logger$9.info("Restoring .kici/ source from tarball", { sourceTarUrl });
3731
+ logger$11.info("Restoring .kici/ source from tarball", { sourceTarUrl });
4085
3732
  const startTime = Date.now();
4086
3733
  let data;
4087
3734
  if (sourceTarUrl.startsWith("file://")) {
@@ -4089,174 +3736,41 @@ async function restoreSource(workDir, sourceTarUrl) {
4089
3736
  data = await fsPromises.readFile(localPath);
4090
3737
  } else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
4091
3738
  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", {
4095
- sizeKB: (data.length / 1024).toFixed(2),
4096
- durationMs
4097
- });
4098
- }
4099
- var logger$9;
4100
- var init_source_restore = __esmMin((() => {
4101
- init_download();
4102
- 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);
3739
+ if (sourceTarDigest) {
3740
+ const actual = createHash("sha256").update(data).digest("hex");
3741
+ if (actual !== sourceTarDigest) throw new Error(`Source tarball hash mismatch: expected ${sourceTarDigest}, got ${actual}. The restored source does not match what the orchestrator dispatched.`);
3742
+ }
3743
+ const kiciDir = path.join(workDir, ".kici");
3744
+ const scratch = path.join(workDir, `.kici.restore-${process.pid}-${Date.now()}`);
4119
3745
  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
- })]);
3746
+ await extractSourceTarball(data, scratch);
3747
+ const extracted = path.join(scratch, ".kici");
3748
+ const src = (await fsPromises.stat(extracted).catch(() => null))?.isDirectory() ? extracted : scratch;
3749
+ const installedDeps = path.join(kiciDir, "node_modules");
3750
+ if (await fsPromises.stat(installedDeps).catch(() => null)) await rename(installedDeps, path.join(src, "node_modules"));
3751
+ await rm(kiciDir, {
3752
+ recursive: true,
3753
+ force: true
3754
+ });
3755
+ await rename(src, kiciDir);
4123
3756
  } finally {
4124
- clearTimeout(timer);
3757
+ await rm(scratch, {
3758
+ recursive: true,
3759
+ force: true
3760
+ }).catch(() => {});
4125
3761
  }
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.$ }
3762
+ const durationMs = Date.now() - startTime;
3763
+ logger$11.info(".kici/ source restored", {
3764
+ sizeKB: (data.length / 1024).toFixed(2),
3765
+ durationMs,
3766
+ verified: sourceTarDigest !== void 0
4164
3767
  });
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
3768
  }
4258
- var init_init_runner = __esmMin((() => {
4259
- init_timeout_util();
3769
+ var logger$11;
3770
+ var init_source_restore = __esmMin((() => {
3771
+ init_download();
3772
+ init_dep_restore();
3773
+ logger$11 = createLogger({ prefix: "source-restore" });
4260
3774
  }));
4261
3775
  //#endregion
4262
3776
  //#region src/bootstrap/ssh-exec.ts
@@ -5029,8 +4543,396 @@ var init_s3_payload_source = __esmMin((() => {
5029
4543
  };
5030
4544
  }
5031
4545
  };
4546
+ })), init_timeout_util = __esmMin((() => {})), MatrixExpansionError;
4547
+ var init_dynamic_job_serializer = __esmMin((() => {
4548
+ init_timeout_util();
4549
+ MatrixExpansionError = class MatrixExpansionError extends Error {
4550
+ jobName;
4551
+ name = "MatrixExpansionError";
4552
+ constructor(jobName, message) {
4553
+ super(message);
4554
+ this.jobName = jobName;
4555
+ Object.setPrototypeOf(this, MatrixExpansionError.prototype);
4556
+ }
4557
+ };
4558
+ }));
4559
+ //#endregion
4560
+ //#region src/execution/sandbox/env-sanitizer.ts
4561
+ /**
4562
+ * Environment sanitization for sandbox execution.
4563
+ *
4564
+ * Uses an explicit allowlist approach: only known-safe system variables are
4565
+ * copied from the host process.env. Agent-internal credentials (KICI_*,
4566
+ * DATABASE_URL, PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are NEVER included.
4567
+ *
4568
+ * 7-layer merge precedence (later overrides earlier):
4569
+ * 1. Allowed system vars from process.env
4570
+ * 2. Sandbox defaults (FORCE_COLOR=1, etc.)
4571
+ * 3. KICI_* system vars (orchestrator-generated, from userEnv)
4572
+ * 4. Org-level environment vars (from orchestrator via contextVars)
4573
+ * 5. Source-level environment overrides (merged into contextVars by orchestrator)
4574
+ * 6. Job env (from lock file env field, evaluated by orchestrator)
4575
+ * 7. setEnv() calls (runtime -- applied at step execution, not here)
4576
+ */
4577
+ /**
4578
+ * Build a sanitized environment for sandbox execution.
4579
+ *
4580
+ * Constructs an environment from scratch using the 7-layer merge:
4581
+ * 1. Explicitly allowed system variables from process.env
4582
+ * 2. Sandbox default variables (FORCE_COLOR, etc.)
4583
+ * 3. User-defined env vars (KICI_* system vars from orchestrator)
4584
+ * 4-5. Environment vars (org-level + source overrides, pre-merged by orchestrator)
4585
+ * 6. Job env (SDK-defined, evaluated by orchestrator)
4586
+ * 7. setEnv() calls (runtime -- not handled here)
4587
+ *
4588
+ * Secrets are NOT injected into environment variables by this function.
4589
+ * They flow through IPC to ctx.secrets and are only exposed via ctx.secrets.expose().
4590
+ *
4591
+ * Agent-internal credentials (KICI_ORCHESTRATOR_URL, DATABASE_URL,
4592
+ * PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are never included because they
4593
+ * are not in the allowlist.
4594
+ *
4595
+ * The `trustedEnv` option (default false) selects the trusted fleet-agent
4596
+ * profile: Layer 1 passes the ambient host env through instead of restricting
4597
+ * to `ALLOWED_SYSTEM_VARS`, minus the agent's own KiCI identity/operational
4598
+ * secrets (the whole `KICI_*` namespace + a small non-`KICI_` infra denylist).
4599
+ * It is read ONLY from agent config (`KICI_TRUSTED_ENV`), never from a dispatch
4600
+ * payload, so a workflow cannot request it. With it OFF the output is
4601
+ * byte-identical to the allowlist-only behavior.
4602
+ *
4603
+ * @param userEnv - Environment variables from workflow config and orchestrator
4604
+ * @param options - Optional extended options for environment layers
4605
+ * @returns A new Record with only safe environment variables
4606
+ */
4607
+ function buildSanitizedEnv(userEnv, options) {
4608
+ const sanitized = {};
4609
+ if (options?.trustedEnv) Object.assign(sanitized, buildTrustedPassthroughEnv(process.env));
4610
+ else for (const key of ALLOWED_SYSTEM_VARS) {
4611
+ const value = process.env[key];
4612
+ if (value !== void 0) sanitized[key] = value;
4613
+ }
4614
+ Object.assign(sanitized, SANDBOX_DEFAULT_VARS);
4615
+ Object.assign(sanitized, userEnv);
4616
+ if (options?.contextVars) Object.assign(sanitized, options.contextVars);
4617
+ if (options?.jobEnv) Object.assign(sanitized, options.jobEnv);
4618
+ return sanitized;
4619
+ }
4620
+ var init_env_sanitizer = __esmMin((() => {}));
4621
+ //#endregion
4622
+ //#region src/execution/sandbox/eval-fork-runner.ts
4623
+ /**
4624
+ * Agent-side driver for the eval child.
4625
+ *
4626
+ * Forks `eval-runner.js` with a sanitized environment, sends one `eval`
4627
+ * request, streams the child's log lines onto the job's synthetic step-0 log,
4628
+ * and resolves with the evaluation's serialized result.
4629
+ *
4630
+ * The accepted inbound set is exactly `ready`, `log.line`, `eval.api.request`,
4631
+ * `eval.result` and `eval.error` — anything else from the child is ignored, so a
4632
+ * customer module that gains code execution in the child cannot reach the step
4633
+ * runner's privileged relays by naming one of their message types.
4634
+ */
4635
+ /**
4636
+ * Run one evaluation in a fresh child and resolve with its result.
4637
+ *
4638
+ * Rejects with the evaluation's own error text on `eval.error`, so the calling
4639
+ * handler reports exactly the message the in-process evaluation used to throw.
4640
+ */
4641
+ function runEvalChild(options) {
4642
+ const env = buildSanitizedEnv({}, { trustedEnv: options.trustedEnv });
4643
+ return new Promise((resolve, reject) => {
4644
+ let settled = false;
4645
+ const child = fork(options.evalRunnerPath, [], {
4646
+ env,
4647
+ cwd: options.request.workDir,
4648
+ stdio: [
4649
+ "pipe",
4650
+ "pipe",
4651
+ "pipe",
4652
+ "ipc"
4653
+ ]
4654
+ });
4655
+ const send = (msg) => {
4656
+ try {
4657
+ child.send(msg);
4658
+ } catch {}
4659
+ };
4660
+ const finish = (fn) => {
4661
+ if (settled) return;
4662
+ settled = true;
4663
+ clearTimeout(hardTimeout);
4664
+ options.signal?.removeEventListener("abort", onAbort);
4665
+ try {
4666
+ child.kill("SIGKILL");
4667
+ } catch {}
4668
+ fn();
4669
+ };
4670
+ const hardTimeout = setTimeout(() => {
4671
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child exceeded its ${EVAL_CHILD_HARD_TIMEOUT_MS}ms ceiling and was killed`)));
4672
+ }, EVAL_CHILD_HARD_TIMEOUT_MS);
4673
+ hardTimeout.unref?.();
4674
+ const onAbort = () => {
4675
+ finish(() => reject(/* @__PURE__ */ new Error("Evaluation cancelled")));
4676
+ };
4677
+ options.signal?.addEventListener("abort", onAbort, { once: true });
4678
+ child.stdout?.resume();
4679
+ let stderrTail = "";
4680
+ child.stderr?.setEncoding("utf8");
4681
+ child.stderr?.on("data", (chunk) => {
4682
+ stderrTail = (stderrTail + chunk).slice(-EVAL_CHILD_STDERR_TAIL_BYTES);
4683
+ });
4684
+ child.on("message", (msg) => {
4685
+ switch (msg.type) {
4686
+ case "ready":
4687
+ send({
4688
+ type: "eval",
4689
+ request: options.request
4690
+ });
4691
+ return;
4692
+ case "log.line":
4693
+ options.onLogLine(msg.line, msg.stream);
4694
+ return;
4695
+ case "eval.api.request":
4696
+ (options.onApiRequest ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available"))))(msg.method, msg.params).then((result) => send({
4697
+ type: "eval.api.response",
4698
+ id: msg.id,
4699
+ result
4700
+ }), (err) => send({
4701
+ type: "eval.api.response",
4702
+ id: msg.id,
4703
+ error: toErrorMessage(err)
4704
+ }));
4705
+ return;
4706
+ case "eval.result":
4707
+ finish(() => resolve(msg.result));
4708
+ return;
4709
+ case "eval.error":
4710
+ finish(() => reject(new Error(msg.error)));
4711
+ return;
4712
+ default: logger$10.warn("Ignoring unexpected message from eval child", { type: msg.type });
4713
+ }
4714
+ });
4715
+ child.on("error", (err) => {
4716
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child failed to start: ${err.message}`)));
4717
+ });
4718
+ child.on("exit", (code, signalName) => {
4719
+ const detail = stderrTail.trim();
4720
+ finish(() => reject(/* @__PURE__ */ new Error(`Evaluation child exited without a result (code=${code}, signal=${signalName})` + (detail ? `: ${detail}` : ""))));
4721
+ });
4722
+ });
4723
+ }
4724
+ var logger$10, EVAL_CHILD_HARD_TIMEOUT_MS, EVAL_CHILD_STDERR_TAIL_BYTES;
4725
+ var init_eval_fork_runner = __esmMin((() => {
4726
+ init_env_sanitizer();
4727
+ logger$10 = createLogger({ prefix: "eval-fork-runner" });
4728
+ EVAL_CHILD_HARD_TIMEOUT_MS = 18e5;
4729
+ EVAL_CHILD_STDERR_TAIL_BYTES = 4096;
4730
+ }));
4731
+ //#endregion
4732
+ //#region src/checkout/changed-files.ts
4733
+ /** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
4734
+ async function buildAuthCtx(auth) {
4735
+ if (!auth) return { args: [] };
4736
+ if (auth.kind === "basic") {
4737
+ const user = auth.user ?? "x-access-token";
4738
+ return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
4739
+ }
4740
+ const sshSetup = await setupSshAuth({
4741
+ privateKey: auth.secret,
4742
+ hostKeyPolicy: auth.sshHostKeyPolicy,
4743
+ knownHosts: auth.sshKnownHostsPem
4744
+ });
4745
+ return {
4746
+ args: [],
4747
+ env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
4748
+ cleanup: () => sshSetup.cleanup()
4749
+ };
4750
+ }
4751
+ function git(workDir, args, ctx) {
4752
+ return execFileSync("git", [
4753
+ ...ctx.args,
4754
+ ...BASE_GIT_ARGS,
4755
+ "-C",
4756
+ workDir,
4757
+ ...args
4758
+ ], {
4759
+ encoding: "utf8",
4760
+ stdio: [
4761
+ "ignore",
4762
+ "pipe",
4763
+ "pipe"
4764
+ ],
4765
+ ...ctx.env && { env: {
4766
+ ...process.env,
4767
+ ...ctx.env
4768
+ } }
4769
+ });
4770
+ }
4771
+ function tryGit(workDir, args, ctx) {
4772
+ try {
4773
+ git(workDir, args, ctx);
4774
+ return true;
4775
+ } catch {
4776
+ return false;
4777
+ }
4778
+ }
4779
+ function parseNameOnly(out) {
4780
+ return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
4781
+ }
4782
+ /** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
4783
+ function ensureCommit(workDir, commitish, ctx) {
4784
+ if (tryGit(workDir, [
4785
+ "cat-file",
4786
+ "-e",
4787
+ `${commitish}^{commit}`
4788
+ ], ctx)) return true;
4789
+ if (tryGit(workDir, [
4790
+ "fetch",
4791
+ "--depth",
4792
+ "1",
4793
+ "origin",
4794
+ commitish
4795
+ ], ctx)) {
4796
+ if (tryGit(workDir, [
4797
+ "cat-file",
4798
+ "-e",
4799
+ `${commitish}^{commit}`
4800
+ ], ctx)) return true;
4801
+ }
4802
+ for (let i = 0; i < MAX_DEEPEN; i++) {
4803
+ if (!tryGit(workDir, [
4804
+ "fetch",
4805
+ `--deepen=${DEEPEN_STEP}`,
4806
+ "origin"
4807
+ ], ctx)) break;
4808
+ if (tryGit(workDir, [
4809
+ "cat-file",
4810
+ "-e",
4811
+ `${commitish}^{commit}`
4812
+ ], ctx)) return true;
4813
+ }
4814
+ return false;
4815
+ }
4816
+ function pushDiff(workDir, before, ctx) {
4817
+ const isZero = !before || ZERO_SHA.test(before);
4818
+ const baseRef = isZero ? EMPTY_TREE_SHA : before;
4819
+ if (!isZero && !ensureCommit(workDir, before, ctx)) return {
4820
+ files: [],
4821
+ status: "unavailable"
4822
+ };
4823
+ return {
4824
+ files: parseNameOnly(git(workDir, [
4825
+ "diff",
4826
+ "--name-only",
4827
+ baseRef,
4828
+ "HEAD"
4829
+ ], ctx)),
4830
+ status: "fetched"
4831
+ };
4832
+ }
4833
+ function prDiff(workDir, base, ctx) {
4834
+ const candidates = [
4835
+ base,
4836
+ `origin/${base}`,
4837
+ "FETCH_HEAD"
4838
+ ];
4839
+ const resolveBase = () => candidates.find((c) => tryGit(workDir, [
4840
+ "rev-parse",
4841
+ "--verify",
4842
+ `${c}^{commit}`
4843
+ ], ctx));
4844
+ let baseRef = resolveBase();
4845
+ if (!baseRef) {
4846
+ if (!ensureCommit(workDir, base, ctx)) return {
4847
+ files: [],
4848
+ status: "unavailable"
4849
+ };
4850
+ baseRef = resolveBase();
4851
+ }
4852
+ if (!baseRef) return {
4853
+ files: [],
4854
+ status: "unavailable"
4855
+ };
4856
+ for (let i = 0; i <= MAX_DEEPEN; i++) {
4857
+ if (tryGit(workDir, [
4858
+ "merge-base",
4859
+ baseRef,
4860
+ "HEAD"
4861
+ ], ctx)) return {
4862
+ files: parseNameOnly(git(workDir, [
4863
+ "diff",
4864
+ "--name-only",
4865
+ `${baseRef}...HEAD`
4866
+ ], ctx)),
4867
+ status: "fetched"
4868
+ };
4869
+ if (!tryGit(workDir, [
4870
+ "fetch",
4871
+ `--deepen=${DEEPEN_STEP}`,
4872
+ "origin"
4873
+ ], ctx)) break;
4874
+ }
4875
+ return {
4876
+ files: [],
4877
+ status: "unavailable"
4878
+ };
4879
+ }
4880
+ /**
4881
+ * Compute the changed-files list from the agent's local clone (HEAD is the
4882
+ * checked-out head commit). Ground truth for job/step rule evaluation. `auth`
4883
+ * (the same credentials used for the clone) authenticates the deepen / fetch
4884
+ * calls so a private remote resolves. Returns `unavailable` for diff-less
4885
+ * events (schedule/tag/manual) or any git failure — never throws.
4886
+ */
4887
+ async function computeChangedFiles(workDir, event, auth) {
4888
+ let ctx;
4889
+ try {
4890
+ if (event.type !== "push" && event.type !== "pull_request") return {
4891
+ files: [],
4892
+ status: "unavailable"
4893
+ };
4894
+ ctx = await buildAuthCtx(auth);
4895
+ if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
4896
+ const base = event.baseBranch ?? event.targetBranch;
4897
+ if (!base) return {
4898
+ files: [],
4899
+ status: "unavailable"
4900
+ };
4901
+ return prDiff(workDir, base, ctx);
4902
+ } catch {
4903
+ return {
4904
+ files: [],
4905
+ status: "unavailable"
4906
+ };
4907
+ } finally {
4908
+ if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
4909
+ }
4910
+ }
4911
+ var EMPTY_TREE_SHA, ZERO_SHA, MAX_DEEPEN, DEEPEN_STEP, BASE_GIT_ARGS;
4912
+ var init_changed_files = __esmMin((() => {
4913
+ init_ssh_auth();
4914
+ EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
4915
+ ZERO_SHA = /^0+$/;
4916
+ MAX_DEEPEN = 4;
4917
+ DEEPEN_STEP = 50;
4918
+ BASE_GIT_ARGS = [
4919
+ "-c",
4920
+ "safe.directory=*",
4921
+ "-c",
4922
+ "core.quotePath=false"
4923
+ ];
5032
4924
  }));
5033
4925
  //#endregion
4926
+ //#region src/execution/global-workflow-env.ts
4927
+ /**
4928
+ * Derive an `owner/repo` identifier from a clone URL, stripping the trailing
4929
+ * `.git` and any `http(s)://host/` prefix.
4930
+ */
4931
+ function repoIdentifierFromUrl(repoUrl) {
4932
+ return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
4933
+ }
4934
+ var init_global_workflow_env = __esmMin((() => {}));
4935
+ //#endregion
5034
4936
  //#region src/execution/streaming-zx-log.ts
5035
4937
  /**
5036
4938
  * Shared factory for the zx `log` callback that streams a subprocess's
@@ -5072,471 +4974,155 @@ function makeStreamingZxLog(emit) {
5072
4974
  [LogStream.enum.stdout]: "",
5073
4975
  [LogStream.enum.stderr]: ""
5074
4976
  };
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
- } } : {}
4977
+ return (entry) => {
4978
+ const e = entry;
4979
+ if (e.kind !== "stdout" && e.kind !== "stderr") return;
4980
+ if (!e.verbose) return;
4981
+ const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
4982
+ const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
4983
+ const lines = (lineBufs[stream] + text).split("\n");
4984
+ lineBufs[stream] = lines.pop();
4985
+ for (const line of lines) if (line) emit(line, stream);
5257
4986
  };
5258
4987
  }
4988
+ var init_streaming_zx_log = __esmMin((() => {}));
5259
4989
  /**
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.
4990
+ * Resolve the changed-files list a `filter` reads for a global eval round and
4991
+ * for a filter-bearing init job alike.
4992
+ *
4993
+ * Ground truth is the agent's own source clone; an already-`fetched` list from
4994
+ * the orchestrator is a free fast-path. A diff-less event (schedule / tag /
4995
+ * manual) resolves to `unavailable`, which makes `ctx.changedFiles` throw
4996
+ * rather than read as an empty diff — a `filter` returning false produces no
4997
+ * run at all, so a silently-empty diff would suppress the workflow with no
4998
+ * artifact anywhere to inspect.
5263
4999
  */
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
5000
+ async function resolveEvalChangedFiles(dispatch, event, sourceDir) {
5001
+ const ev = event;
5002
+ if (ev.changedFilesStatus === "fetched") return {
5003
+ files: ev.changedFiles ?? [],
5004
+ status: "fetched"
5298
5005
  };
5299
- throw new MatrixExpansionError(jobName, `Job '${jobName}': dynamic matrix function returned an unsupported value (expected array or object, got ${typeof values})`);
5006
+ return computeChangedFiles(sourceDir, event, dispatch.sourceAuth ?? dispatch.workflowAuth ?? (dispatch.token ? {
5007
+ kind: "basic",
5008
+ user: "x-access-token",
5009
+ secret: dispatch.token
5010
+ } : void 0));
5300
5011
  }
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
5012
  /**
5318
- * Derive an `owner/repo` identifier from a clone URL, stripping the trailing
5319
- * `.git` and any `http(s)://host/` prefix.
5013
+ * Materialize the source tree a non-global workflow's `filter` reads through
5014
+ * `ctx.sourceRepo.path`.
5015
+ *
5016
+ * An init or dynamic-eval job normally restores only `.kici/` from the cached
5017
+ * source tarball — enough to import the workflow module, but a directory with no
5018
+ * repo in it. A filter that reads a file or shells out against that path would
5019
+ * get a confidently wrong answer, and `changedFiles` could not be computed at
5020
+ * all, so a filter-bearing job clones the source repo into a sibling directory.
5021
+ *
5022
+ * When no tarball was attached the job already cloned the whole repo into
5023
+ * `workDir`, and that clone is reused rather than duplicated — including the
5024
+ * local working-tree case, where there is no repo url and `workDir` IS the tree.
5025
+ *
5026
+ * A tarball with no repo url is the one combination that cannot be honoured:
5027
+ * `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
5028
+ * would hand the filter a directory in which every path test answers "absent" —
5029
+ * the exact silent lie this function exists to prevent — so it throws instead.
5320
5030
  */
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);
5031
+ async function ensureFilterSourceDir(dispatch, workDir) {
5032
+ if (!dispatch.sourceTarUrl) return workDir;
5033
+ 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.");
5034
+ const sourceDir = join(workDir, FILTER_SOURCE_DIRNAME);
5035
+ const sourceAuth = dispatch.sourceAuth;
5036
+ await gitClone({
5037
+ repoUrl: dispatch.repoUrl,
5038
+ ref: dispatch.ref,
5039
+ sha: dispatch.sha,
5040
+ workDir: sourceDir,
5041
+ gitAuth: sourceAuth,
5042
+ token: sourceAuth ? void 0 : dispatch.token
5043
+ });
5044
+ return sourceDir;
5395
5045
  }
5396
5046
  /**
5397
- * Run every `DynamicJobFn` the workflow declares and serialize the result.
5047
+ * Build the context a non-global workflow's `filter` is evaluated against.
5048
+ *
5049
+ * `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
5050
+ * means — so both carry the same identifier, path, ref, and sha. The zx shell is
5051
+ * rooted at the source tree and streams into the evaluating step's log, matching
5052
+ * what the global eval round hands its own filters.
5398
5053
  *
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.
5054
+ * They are two distinct objects all the same. Being the same repo is a fact
5055
+ * about their VALUES, not a licence to hand the author one object under two
5056
+ * names: a filter that mutated `ctx.sourceRepo` would silently see
5057
+ * `ctx.workflowRepo` change with it, which happens on no other path.
5403
5058
  */
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
5059
+ async function buildInitFilterInput(dispatch, event, workDir, emit) {
5060
+ const sourceDir = await ensureFilterSourceDir(dispatch, workDir);
5061
+ const diff = await resolveEvalChangedFiles(dispatch, event, sourceDir);
5062
+ const repo = {
5063
+ identifier: repoIdentifierFromUrl(dispatch.repoUrl),
5064
+ path: sourceDir,
5065
+ ref: dispatch.ref,
5066
+ sha: dispatch.sha
5414
5067
  };
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
5068
  return {
5456
- workflowName: candidate.workflowName,
5457
- run: true,
5458
- ...jobs && { jobs }
5069
+ sourceRepo: repo,
5070
+ workflowRepo: { ...repo },
5071
+ changedFiles: diff.files,
5072
+ changedFilesStatus: diff.status,
5073
+ env: process.env,
5074
+ $: await buildEvalShell(sourceDir, emit)
5459
5075
  };
5460
5076
  }
5461
5077
  /**
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.
5078
+ * Build the per-invocation zx `$` a global eval round hands to filters and
5079
+ * generators, so a `await $\`…\`` inside one is visible in the eval step's log.
5080
+ *
5081
+ * **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
5082
+ * snapshot taken when the shell is built, which is before the round applies the
5083
+ * seven `KICI_*` keys — so a filter that shells out (`$\`printenv
5084
+ * KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
5085
+ * here while the sandbox re-evaluation's ambient `$` resolves `process.env`
5086
+ * after `setupGlobalWorkflowEnv` has run and does see them. That is the same
5087
+ * two-worlds determinism failure the cwd choice below exists to prevent, one
5088
+ * layer down. Passing the live reference reproduces the ambient `$`'s own
5089
+ * behaviour, which is what the sandbox uses.
5481
5090
  *
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.
5091
+ * `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
5092
+ * decrypted secret never leaks into the log.
5489
5093
  *
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.
5094
+ * `emit` is a callback rather than the `LogStreamer` itself so the caller can
5095
+ * route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
5096
+ * flag and `addLine` buffers unconditionally, so a subprocess line arriving
5097
+ * after the step was reported would otherwise emit a `log.chunk` for a terminal
5098
+ * step. That is the likeliest path for it — an orphaned candidate is usually
5099
+ * orphaned *because* it is waiting on a subprocess.
5493
5100
  */
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
- }
5101
+ async function buildEvalShell(cwd, emit) {
5102
+ const { $: zx$ } = await import("zx");
5103
+ return zx$({
5104
+ cwd,
5105
+ env: process.env,
5106
+ verbose: true,
5107
+ quiet: false,
5108
+ log: makeStreamingZxLog(emit)
5109
+ });
5499
5110
  }
5500
5111
  /**
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.
5112
+ * Build the result-aware `ctx.needs` for a dynamic eval from its frozen upstream
5113
+ * snapshot. Returns undefined for an event-only generator (no snapshot).
5504
5114
  */
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 };
5115
+ function buildEvalNeedsContext(config) {
5116
+ if (!config.resultAware || !config.upstreamSnapshot) return void 0;
5117
+ return buildNeedsContext(config.upstreamSnapshot, config.declaredNeeds ?? []);
5526
5118
  }
5527
- var NOOP_LOG;
5528
- var init_global_eval_runner = __esmMin((() => {
5529
- init_timeout_util();
5530
- init_generator_context();
5119
+ var FILTER_SOURCE_DIRNAME;
5120
+ var init_eval_context = __esmMin((() => {
5121
+ init_git_clone();
5122
+ init_changed_files();
5531
5123
  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
- };
5124
+ init_streaming_zx_log();
5125
+ FILTER_SOURCE_DIRNAME = "__kici_filter_source__";
5540
5126
  })), DEFAULT_MAX_LOG_SIZE_BYTES, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_LINE_THRESHOLD, PAUSE_SAFETY_TIMEOUT_MS, LogStreamer;
5541
5127
  var init_log_streamer = __esmMin((() => {
5542
5128
  init_prometheus();
@@ -5953,7 +5539,7 @@ async function applyOverlay(config) {
5953
5539
  const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
5954
5540
  const { path: tmpDir, cleanup } = await makeTempDir("overlay");
5955
5541
  try {
5956
- logger$8.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
5542
+ logger$9.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
5957
5543
  let encryptedData;
5958
5544
  try {
5959
5545
  encryptedData = await downloadUrl(tarballUrl);
@@ -5964,7 +5550,7 @@ async function applyOverlay(config) {
5964
5550
  const orchPrivKeyBuf = Buffer.from(orchestratorPrivateKey, "base64");
5965
5551
  const aesKey = deriveSharedSecret(orchPrivKeyBuf, cliPubKeyBuf);
5966
5552
  const decryptedData = decryptBuffer(encryptedData, aesKey);
5967
- logger$8.info("Extracting overlay tarball", { size: decryptedData.length });
5553
+ logger$9.info("Extracting overlay tarball", { size: decryptedData.length });
5968
5554
  const extractDir = path.join(tmpDir, "extracted");
5969
5555
  await fsPromises.mkdir(extractDir, { recursive: true });
5970
5556
  try {
@@ -6013,10 +5599,10 @@ async function applyOverlay(config) {
6013
5599
  await fsPromises.unlink(targetPath);
6014
5600
  filesDeleted++;
6015
5601
  } catch {
6016
- logger$8.debug("Deletion target not found, skipping", { file });
5602
+ logger$9.debug("Deletion target not found, skipping", { file });
6017
5603
  }
6018
5604
  }
6019
- logger$8.info("Overlay applied successfully", {
5605
+ logger$9.info("Overlay applied successfully", {
6020
5606
  filesApplied,
6021
5607
  filesDeleted
6022
5608
  });
@@ -6029,10 +5615,10 @@ async function applyOverlay(config) {
6029
5615
  await cleanup().catch(() => {});
6030
5616
  }
6031
5617
  }
6032
- var logger$8, IV_LENGTH$1, AUTH_TAG_LENGTH;
5618
+ var logger$9, IV_LENGTH$1, AUTH_TAG_LENGTH;
6033
5619
  var init_overlay_applier = __esmMin((() => {
6034
5620
  init_download();
6035
- logger$8 = createLogger({ prefix: "overlay-applier" });
5621
+ logger$9 = createLogger({ prefix: "overlay-applier" });
6036
5622
  IV_LENGTH$1 = 12;
6037
5623
  AUTH_TAG_LENGTH = 16;
6038
5624
  }));
@@ -6168,10 +5754,12 @@ var init_npm_registry_config = __esmMin((() => {}));
6168
5754
  *
6169
5755
  * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
6170
5756
  * (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`.
5757
+ * workflow-loader work unchanged. `enableScripts: false` keeps dependency
5758
+ * lifecycle scripts from running at all for every install, not only one
5759
+ * against a private registry — the same security model as npm/pnpm/classic
5760
+ * `--ignore-scripts`. An operator opts back in with
5761
+ * `KICI_ALLOW_INSTALL_SCRIPTS=true`, which arrives here as
5762
+ * `ignoreScripts: false`.
6175
5763
  *
6176
5764
  * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
6177
5765
  * shapes as the npm overlay so `dep-installer` can pick either by flavor.
@@ -6218,8 +5806,8 @@ async function applyYarnrcBerryConfig(args) {
6218
5806
  };
6219
5807
  const tokenEnv = {};
6220
5808
  const tokensForRedaction = [];
5809
+ if (args.ignoreScripts !== false) merged.enableScripts = false;
6221
5810
  if (hasPrivateRegistry) {
6222
- merged.enableScripts = false;
6223
5811
  const npmScopes = { ...doc.npmScopes ?? {} };
6224
5812
  for (let i = 0; i < registries.length; i++) {
6225
5813
  const reg = registries[i];
@@ -6561,9 +6149,11 @@ var init_workspace_siblings = __esmMin((() => {}));
6561
6149
  * Security: the install runs with an isolated per-invocation cache/store
6562
6150
  * directory to prevent cache poisoning across build jobs — a malicious
6563
6151
  * 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.
6152
+ * The install runs with `--ignore-scripts` for every package manager. A
6153
+ * lifecycle script in a committed `package.json` is customer code the agent
6154
+ * never agreed to execute: it would run wherever the install runs, which for a
6155
+ * step-child install is the process holding the job's secrets. Operators who
6156
+ * genuinely need it set `KICI_ALLOW_INSTALL_SCRIPTS=true` on the agent.
6567
6157
  */
6568
6158
  /**
6569
6159
  * Detect the package manager for the cloned repo from its committed manifests.
@@ -6594,9 +6184,11 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
6594
6184
  * between build jobs; the directory is removed after installation.
6595
6185
  *
6596
6186
  * 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.
6187
+ * `.kici/.npmrc` overlay is synthesized for the install and restored in
6188
+ * `finally`.
6189
+ *
6190
+ * Lifecycle scripts are disabled for every package manager unless the operator
6191
+ * set `opts.allowInstallScripts`.
6600
6192
  *
6601
6193
  * @param kiciDir - Path to the `.kici/` directory containing package.json.
6602
6194
  * @param opts - Optional registry / installEnv / repoRoot configuration.
@@ -6605,7 +6197,7 @@ async function installDeps(kiciDir, opts = {}) {
6605
6197
  const repoRoot = opts.repoRoot ?? dirname(kiciDir);
6606
6198
  const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
6607
6199
  const yarnFlavor = packageManager === PackageManager.Yarn ? await detectKiciYarnFlavor(repoRoot, kiciDir) : YarnFlavor.Classic;
6608
- logger$7.info("Installing deps inline", {
6200
+ logger$8.info("Installing deps inline", {
6609
6201
  packageManager,
6610
6202
  yarnFlavor,
6611
6203
  dir: kiciDir
@@ -6618,13 +6210,15 @@ async function installDeps(kiciDir, opts = {}) {
6618
6210
  yarnFlavor
6619
6211
  });
6620
6212
  const startTime = Date.now();
6621
- const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
6213
+ const ignoreScripts = opts.allowInstallScripts !== true;
6214
+ const baseEnv = opts.baseEnv ?? process.env;
6622
6215
  const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
6623
6216
  const registryConfig = isBerry ? await applyYarnrcBerryConfig({
6624
6217
  kiciDir,
6625
6218
  npmRegistries: opts.npmRegistries,
6626
6219
  installEnvSecrets: opts.installEnvSecrets,
6627
- jobIdShort: opts.jobIdShort ?? "00000000"
6220
+ jobIdShort: opts.jobIdShort ?? "00000000",
6221
+ ignoreScripts
6628
6222
  }) : await applyNpmRegistryConfig({
6629
6223
  kiciDir,
6630
6224
  npmRegistries: opts.npmRegistries,
@@ -6634,22 +6228,26 @@ async function installDeps(kiciDir, opts = {}) {
6634
6228
  try {
6635
6229
  if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
6636
6230
  kiciDir,
6637
- hasPrivateRegistry,
6638
- registryConfig
6231
+ ignoreScripts,
6232
+ registryConfig,
6233
+ baseEnv
6639
6234
  });
6640
6235
  else if (isBerry) await runYarnBerryInstall({
6641
6236
  kiciDir,
6642
- registryConfig
6237
+ registryConfig,
6238
+ baseEnv
6643
6239
  });
6644
6240
  else if (packageManager === PackageManager.Yarn) await runYarnInstall({
6645
6241
  kiciDir,
6646
- hasPrivateRegistry,
6647
- registryConfig
6242
+ ignoreScripts,
6243
+ registryConfig,
6244
+ baseEnv
6648
6245
  });
6649
6246
  else await runNpmInstall({
6650
6247
  kiciDir,
6651
- hasPrivateRegistry,
6652
- registryConfig
6248
+ ignoreScripts,
6249
+ registryConfig,
6250
+ baseEnv
6653
6251
  });
6654
6252
  } catch (e) {
6655
6253
  const tokens = registryConfig.tokensForRedaction;
@@ -6659,29 +6257,35 @@ async function installDeps(kiciDir, opts = {}) {
6659
6257
  } finally {
6660
6258
  await registryConfig.cleanup();
6661
6259
  }
6662
- if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
6663
- if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
6260
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot, baseEnv);
6261
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv);
6664
6262
  const durationMs = Date.now() - startTime;
6665
6263
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
6666
- logger$7.info("Deps installed inline", {
6264
+ logger$8.info("Deps installed inline", {
6667
6265
  packageManager,
6668
6266
  durationMs
6669
6267
  });
6670
6268
  }
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;
6269
+ /**
6270
+ * Build the Node binary directory onto PATH so spawned tools find `node`.
6271
+ *
6272
+ * `baseEnv` is the caller's declared environment for the subprocess. It
6273
+ * defaults to `process.env` because inside the runner child that IS the
6274
+ * sanitized job environment; an agent-process caller passes a sanitized base.
6275
+ */
6276
+ function envWithNodeOnPath(extraEnv, nodeDir, baseEnv = process.env) {
6277
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = baseEnv;
6674
6278
  return {
6675
6279
  ...restEnv,
6676
6280
  ...extraEnv,
6677
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
6281
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${restEnv.PATH ?? ""}`
6678
6282
  };
6679
6283
  }
6680
6284
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
6681
6285
  async function runNpmInstall(args) {
6682
6286
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
6683
6287
  const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
6684
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6288
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6685
6289
  const buildArgs = (...prefix) => {
6686
6290
  const a = [
6687
6291
  ...prefix,
@@ -6691,7 +6295,7 @@ async function runNpmInstall(args) {
6691
6295
  "--no-audit",
6692
6296
  "--no-fund"
6693
6297
  ];
6694
- if (args.hasPrivateRegistry) a.push("--ignore-scripts");
6298
+ if (args.ignoreScripts) a.push("--ignore-scripts");
6695
6299
  return a;
6696
6300
  };
6697
6301
  try {
@@ -6720,7 +6324,7 @@ async function runPnpmInstall(args) {
6720
6324
  await assertPnpmAvailable();
6721
6325
  const { nodeDir } = resolveNpm();
6722
6326
  const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
6723
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6327
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6724
6328
  const argv = [
6725
6329
  "install",
6726
6330
  `--config.store-dir=${storeDir}`,
@@ -6729,7 +6333,7 @@ async function runPnpmInstall(args) {
6729
6333
  "--config.side-effects-cache=false",
6730
6334
  PNPM_IGNORE_BUILD_GATE_ARG
6731
6335
  ];
6732
- if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
6336
+ if (args.ignoreScripts) argv.push("--ignore-scripts");
6733
6337
  try {
6734
6338
  process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
6735
6339
  await execFileAsync("pnpm", argv, {
@@ -6743,7 +6347,7 @@ async function runPnpmInstall(args) {
6743
6347
  }
6744
6348
  }
6745
6349
  /** Pure: argv for `yarn install` with an isolated cache folder. */
6746
- function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
6350
+ function buildYarnInstallArgs(cacheDir, ignoreScripts) {
6747
6351
  const a = [
6748
6352
  "install",
6749
6353
  "--cache-folder",
@@ -6751,7 +6355,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
6751
6355
  "--non-interactive",
6752
6356
  "--no-progress"
6753
6357
  ];
6754
- if (hasPrivateRegistry) a.push("--ignore-scripts");
6358
+ if (ignoreScripts) a.push("--ignore-scripts");
6755
6359
  return a;
6756
6360
  }
6757
6361
  /**
@@ -6766,8 +6370,8 @@ async function runYarnInstall(args) {
6766
6370
  await assertYarnAvailable();
6767
6371
  const { nodeDir } = resolveNpm();
6768
6372
  const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
6769
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
6770
- const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
6373
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
6374
+ const argv = buildYarnInstallArgs(cacheDir, args.ignoreScripts);
6771
6375
  try {
6772
6376
  process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
6773
6377
  await execFileAsync("yarn", argv, {
@@ -6797,7 +6401,7 @@ async function runYarnBerryInstall(args) {
6797
6401
  await assertYarnAvailable();
6798
6402
  const { nodeDir } = resolveNpm();
6799
6403
  const env = {
6800
- ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
6404
+ ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv),
6801
6405
  COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
6802
6406
  };
6803
6407
  const argv = buildYarnBerryInstallArgs();
@@ -6828,11 +6432,11 @@ async function assertYarnAvailable() {
6828
6432
  * Deep cross-sibling build chains may build out of strict topological order —
6829
6433
  * real `.kici` closures are shallow.
6830
6434
  */
6831
- async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
6435
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv) {
6832
6436
  const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
6833
6437
  if (siblings.length === 0) return;
6834
6438
  const { nodeDir } = resolveNpm();
6835
- const env = envWithNodeOnPath({}, nodeDir);
6439
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
6836
6440
  for (const rel of [...siblings].reverse()) {
6837
6441
  const sibDir = join(repoRoot, rel);
6838
6442
  if (!await siblingHasBuildScript(sibDir)) continue;
@@ -6876,9 +6480,9 @@ async function siblingHasBuildScript(sibDir) {
6876
6480
  * the subprocess stderr/stdout is folded into the thrown error so the job's
6877
6481
  * failure message names the real cause instead of a bare "Command failed".
6878
6482
  */
6879
- async function buildWorkspaceClosure(repoRoot) {
6483
+ async function buildWorkspaceClosure(repoRoot, baseEnv) {
6880
6484
  const { nodeDir } = resolveNpm();
6881
- const env = envWithNodeOnPath({}, nodeDir);
6485
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
6882
6486
  const argv = [
6883
6487
  "--filter",
6884
6488
  "{.kici}^...",
@@ -6920,13 +6524,13 @@ function logSubprocessStreams(e, tokens) {
6920
6524
  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
6525
  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
6526
  }
6923
- var logger$7, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
6527
+ var logger$8, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
6924
6528
  var init_dep_installer = __esmMin((() => {
6925
6529
  init_npm_registry_config();
6926
6530
  init_yarnrc_berry_config();
6927
6531
  init_validate_kici_deps();
6928
6532
  init_workspace_siblings();
6929
- logger$7 = createLogger({ prefix: "dep-installer" });
6533
+ logger$8 = createLogger({ prefix: "dep-installer" });
6930
6534
  execFileAsync = promisify(execFile);
6931
6535
  INSTALL_TIMEOUT_MS = 6e5;
6932
6536
  INSTALL_MAX_BUFFER = 134217728;
@@ -6974,7 +6578,7 @@ async function packNodeModules(kiciDir) {
6974
6578
  const nmRoot = packageManager === PackageManager.Yarn ? resolveYarnNodeModulesRoot(workDir, kiciDir) : join(kiciDir, "node_modules");
6975
6579
  if (!existsSync(nmRoot)) throw new Error(`node_modules not found at ${nmRoot}`);
6976
6580
  const entries = await closureEntries(workDir, kiciDir, packageManager);
6977
- logger$6.info("Packing dependency closure into tarball", {
6581
+ logger$7.info("Packing dependency closure into tarball", {
6978
6582
  dir: workDir,
6979
6583
  packageManager,
6980
6584
  entries
@@ -6990,7 +6594,7 @@ async function packNodeModules(kiciDir) {
6990
6594
  const tarball = Buffer.concat(chunks);
6991
6595
  const hash = sha256(tarball);
6992
6596
  const sizeMB = (tarball.length / 1048576).toFixed(2);
6993
- logger$6.info("Dependency closure packed", {
6597
+ logger$7.info("Dependency closure packed", {
6994
6598
  sizeMB,
6995
6599
  hash: hash.slice(0, 12),
6996
6600
  durationMs: Date.now() - startTime
@@ -7024,73 +6628,109 @@ async function closureEntries(workDir, kiciDir, packageManager) {
7024
6628
  }
7025
6629
  return [relative(workDir, join(kiciDir, "node_modules"))];
7026
6630
  }
7027
- var logger$6;
6631
+ var logger$7;
7028
6632
  var init_dep_packer = __esmMin((() => {
7029
6633
  init_workspace_siblings();
7030
- logger$6 = createLogger({ prefix: "dep-packer" });
6634
+ logger$7 = createLogger({ prefix: "dep-packer" });
7031
6635
  }));
7032
6636
  //#endregion
7033
- //#region src/execution/sandbox/env-sanitizer.ts
6637
+ //#region src/execution/sandbox/log-masker.ts
7034
6638
  /**
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)
6639
+ * Escape regex special characters in a string.
7049
6640
  */
6641
+ function escapeRegExp(s) {
6642
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6643
+ }
7050
6644
  /**
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.
6645
+ * Create a LogMasker initialized with all secret values from the request.
7067
6646
  *
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.
6647
+ * Collects values from both flat secrets and all namespaced context secrets,
6648
+ * deduplicating before registration.
7075
6649
  *
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
6650
+ * Both the runner child and the agent-side fork runner build a masker from the
6651
+ * same request, so the crash tail the agent assembles from the child's stderr is
6652
+ * masked with the same value set the child used for its own log lines.
7079
6653
  */
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;
6654
+ function createSecretMasker(request) {
6655
+ const masker = new LogMasker();
6656
+ const allSecrets = {};
6657
+ if (request.secrets) Object.assign(allSecrets, request.secrets);
6658
+ if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
6659
+ masker.registerSecrets(allSecrets);
6660
+ return masker;
7092
6661
  }
7093
- var init_env_sanitizer = __esmMin((() => {}));
6662
+ var MIN_MASK_LENGTH, LogMasker;
6663
+ var init_log_masker = __esmMin((() => {
6664
+ MIN_MASK_LENGTH = 3;
6665
+ LogMasker = class {
6666
+ pattern = null;
6667
+ /**
6668
+ * Register secret values to be masked in log output.
6669
+ *
6670
+ * Values shorter than 3 characters are skipped to avoid false positives.
6671
+ * Base64-encoded variants of each qualifying secret are also registered,
6672
+ * preventing leaks when secrets appear base64-encoded in logs (e.g.,
6673
+ * Authorization: Basic headers, base64-encoded config values).
6674
+ * Values are sorted by length descending so longer values are matched first
6675
+ * (prevents partial masking when one secret is a substring of another).
6676
+ *
6677
+ * Multi-line values additionally register each of their individual lines.
6678
+ * Log output is split into lines before it reaches the masker, so a value
6679
+ * containing a newline can never match as a whole — a PEM private key or a
6680
+ * kubeconfig would otherwise stream in clear text. Two consequences of the
6681
+ * per-line registration are deliberate:
6682
+ *
6683
+ * - `MIN_MASK_LENGTH` is 3, so short structural lines of a structured secret
6684
+ * are registered too. A `---` YAML separator or a bare `{` from a
6685
+ * service-account JSON is masked wherever it appears in that job's logs.
6686
+ * - PEM header and footer lines (`-----BEGIN OPENSSH PRIVATE KEY-----`) are
6687
+ * not secret on their own and are masked as a side effect.
6688
+ *
6689
+ * Both are strictly safer than leaking the body, and no heuristic separates a
6690
+ * structural line from a body line without risking the reverse mistake.
6691
+ */
6692
+ registerSecrets(secrets) {
6693
+ const seen = /* @__PURE__ */ new Set();
6694
+ const values = [];
6695
+ const add = (candidate) => {
6696
+ if (candidate.length < MIN_MASK_LENGTH || seen.has(candidate)) return;
6697
+ seen.add(candidate);
6698
+ values.push(candidate);
6699
+ const b64 = Buffer.from(candidate).toString("base64");
6700
+ if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
6701
+ seen.add(b64);
6702
+ values.push(b64);
6703
+ }
6704
+ };
6705
+ for (const value of Object.values(secrets)) {
6706
+ add(value);
6707
+ if (value.includes("\n")) for (const rawLine of value.split("\n")) add(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
6708
+ }
6709
+ if (values.length === 0) {
6710
+ this.pattern = null;
6711
+ return;
6712
+ }
6713
+ values.sort((a, b) => b.length - a.length);
6714
+ this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
6715
+ }
6716
+ /**
6717
+ * Mask all registered secret values in a log line.
6718
+ *
6719
+ * Returns the line unchanged if no secrets are registered.
6720
+ */
6721
+ mask(line) {
6722
+ if (!this.pattern) return line;
6723
+ this.pattern.lastIndex = 0;
6724
+ return line.replace(this.pattern, "***");
6725
+ }
6726
+ /**
6727
+ * Returns true if any maskable secrets are registered.
6728
+ */
6729
+ hasSecrets() {
6730
+ return this.pattern !== null;
6731
+ }
6732
+ };
6733
+ }));
7094
6734
  //#endregion
7095
6735
  //#region src/execution/sandbox/secret-encryption.ts
7096
6736
  /**
@@ -7221,8 +6861,10 @@ function buildRequest(dispatch, workDir, extra) {
7221
6861
  sourceAuth: dispatch.sourceAuth,
7222
6862
  workflowAuth: dispatch.workflowAuth,
7223
6863
  ...extra?.credentialHelperPath ? { credentialHelperPath: extra.credentialHelperPath } : {},
6864
+ ...extra?.allowInstallScripts ? { allowInstallScripts: true } : {},
7224
6865
  sourceTarUrl: dispatch.sourceTarUrl,
7225
6866
  sourceTarHash: dispatch.sourceTarHash,
6867
+ sourceTarDigest: dispatch.sourceTarDigest,
7226
6868
  depsUrl: dispatch.depsUrl,
7227
6869
  depsHash: dispatch.depsHash,
7228
6870
  workflowName: jobConfig.workflowName ?? "",
@@ -7252,6 +6894,7 @@ function buildRequest(dispatch, workDir, extra) {
7252
6894
  cliPublicKey: jobConfig.cliPublicKey,
7253
6895
  orchestratorPrivateKey: jobConfig.orchestratorPrivateKey,
7254
6896
  runPublicKey: dispatch.runPublicKey,
6897
+ provenanceContext: dispatch.provenanceContext,
7255
6898
  context: jobConfig.context,
7256
6899
  contextVars: jobConfig.contextVars,
7257
6900
  jobEnv: jobConfig.jobEnv,
@@ -7398,6 +7041,56 @@ function fileCloneSourceBinds(repoUrl) {
7398
7041
  }
7399
7042
  }
7400
7043
  /**
7044
+ * Resolve a `KICI_RUNNER_USER` spec to numeric ids.
7045
+ *
7046
+ * Accepts `uid`, `uid:gid`, or a user name. A name is resolved through `id`,
7047
+ * which is present on every POSIX host the bare-metal backend supports; a uid
7048
+ * with no gid takes that user's primary group.
7049
+ *
7050
+ * Throws on an unresolvable spec rather than silently falling back to the
7051
+ * agent's own uid — the operator set this to get a boundary, so quietly not
7052
+ * having one is the worst outcome.
7053
+ */
7054
+ function resolveRunnerUser(spec) {
7055
+ if (!spec) return void 0;
7056
+ const [userPart, groupPart] = spec.split(":");
7057
+ const runId = (args) => {
7058
+ const out = execFileSync("id", args, { encoding: "utf8" }).trim();
7059
+ if (!out) throw new Error(`empty output from \`id ${args.join(" ")}\``);
7060
+ return out;
7061
+ };
7062
+ try {
7063
+ const uid = /^\d+$/.test(userPart ?? "") ? Number(userPart) : Number(runId(["-u", userPart]));
7064
+ const gid = groupPart === void 0 ? Number(runId(["-g", userPart])) : /^\d+$/.test(groupPart) ? Number(groupPart) : Number(runId(["-g", groupPart]));
7065
+ if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error("non-numeric id");
7066
+ return {
7067
+ uid,
7068
+ gid
7069
+ };
7070
+ } catch (err) {
7071
+ throw new Error(`KICI_RUNNER_USER='${spec}' could not be resolved to a uid/gid: ${toErrorMessage(err)}`);
7072
+ }
7073
+ }
7074
+ /**
7075
+ * Hand the job workdir to the runner user before the child starts.
7076
+ *
7077
+ * Without this the child cannot write its own workspace. A failure is fatal:
7078
+ * the operator asked for a dedicated uid, and running one that cannot write is
7079
+ * a broken job wearing the clothes of a boundary.
7080
+ */
7081
+ function chownWorkDirToRunner(workDir, identity) {
7082
+ if (!workDir) return;
7083
+ try {
7084
+ execFileSync("chown", [
7085
+ "-R",
7086
+ `${identity.uid}:${identity.gid}`,
7087
+ workDir
7088
+ ], { stdio: "pipe" });
7089
+ } catch (err) {
7090
+ throw new Error(`failed to chown job workdir ${workDir} to ${identity.uid}:${identity.gid} for KICI_RUNNER_USER: ${toErrorMessage(err)}`);
7091
+ }
7092
+ }
7093
+ /**
7401
7094
  * Spawn the workflow-runner child process. Returns the child handle and a
7402
7095
  * boolean indicating whether the spawn produced a usable PID. On Windows
7403
7096
  * services (shawl) fork() can fail silently when IPC pipes cannot be set up;
@@ -7405,6 +7098,12 @@ function fileCloneSourceBinds(repoUrl) {
7405
7098
  */
7406
7099
  function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7407
7100
  const { runnerPath, useBwrap = false, networkIsolation = false, extraReadOnlyBinds = [] } = options;
7101
+ const identity = resolveRunnerUser(options.runnerUser);
7102
+ if (identity) chownWorkDirToRunner(effectiveWorkDir, identity);
7103
+ const identityOpts = identity ? {
7104
+ uid: identity.uid,
7105
+ gid: identity.gid
7106
+ } : {};
7408
7107
  let child;
7409
7108
  if (useBwrap) {
7410
7109
  const bwrapArgs = buildBwrapArgs(effectiveWorkDir, process.execPath, networkIsolation, runnerPath, extraReadOnlyBinds);
@@ -7419,7 +7118,8 @@ function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7419
7118
  "pipe",
7420
7119
  "pipe",
7421
7120
  "ipc"
7422
- ]
7121
+ ],
7122
+ ...identityOpts
7423
7123
  });
7424
7124
  } else child = fork(runnerPath, [], {
7425
7125
  env: sanitizedEnv,
@@ -7430,13 +7130,29 @@ function spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir) {
7430
7130
  "ipc"
7431
7131
  ],
7432
7132
  cwd: effectiveWorkDir || void 0,
7433
- detached: options.detachProcessGroup === true
7133
+ detached: options.detachProcessGroup === true,
7134
+ ...identityOpts
7434
7135
  });
7435
7136
  return {
7436
7137
  child,
7437
7138
  pidAssigned: Boolean(child.pid)
7438
7139
  };
7439
7140
  }
7141
+ /**
7142
+ * True when the operator asked for the child's raw stdio on the agent's own
7143
+ * stderr (`KICI_RUNNER_DEBUG_STDIO=true`).
7144
+ *
7145
+ * Off by default. The agent's stderr is not a run log: for a scaler-spawned
7146
+ * agent the orchestrator's LogForwarder copies it into journald and Loki, where
7147
+ * it is readable without the `runs:read` check that guards the run log and with
7148
+ * no per-org scoping. The child's masked `log.line` stream is the log; echoing
7149
+ * the same bytes to stderr would publish the unmasked copy beside it.
7150
+ *
7151
+ * Read per call rather than cached so a test can flip it between cases.
7152
+ */
7153
+ function debugStdioEnabled() {
7154
+ return process.env.KICI_RUNNER_DEBUG_STDIO === "true";
7155
+ }
7440
7156
  /** Drain the child's stdout (preventing pipe-buffer deadlock) and capture
7441
7157
  * the last N stderr lines for crash diagnostics. The returned array is
7442
7158
  * mutated in place by the stderr listener. */
@@ -7444,7 +7160,7 @@ function setupChildStdioCapture(child) {
7444
7160
  const stderrLines = [];
7445
7161
  const MAX_STDERR_LINES = 20;
7446
7162
  if (child.stdout) child.stdout.on("data", (chunk) => {
7447
- process.stderr.write(`[workflow-runner:stdout] ${chunk.toString().trimEnd()}\n`);
7163
+ if (debugStdioEnabled()) process.stderr.write(`[workflow-runner:stdout] ${chunk.toString().trimEnd()}\n`);
7448
7164
  });
7449
7165
  if (child.stderr) {
7450
7166
  let stderrBuffer = "";
@@ -7456,7 +7172,7 @@ function setupChildStdioCapture(child) {
7456
7172
  if (!line.trim()) continue;
7457
7173
  stderrLines.push(line);
7458
7174
  if (stderrLines.length > MAX_STDERR_LINES) stderrLines.shift();
7459
- process.stderr.write(`[workflow-runner:stderr] ${line}\n`);
7175
+ if (debugStdioEnabled()) process.stderr.write(`[workflow-runner:stderr] ${line}\n`);
7460
7176
  }
7461
7177
  });
7462
7178
  }
@@ -7629,7 +7345,7 @@ function handleJobComplete(msg, dispatch, ctx) {
7629
7345
  /** Dispatch one IPC message from the runner. Centralises the message switch
7630
7346
  * so the createForkRunner body stays small. */
7631
7347
  function relayChildIpcMessage(msg, dispatch, ctx) {
7632
- const logDetail = msg.type === "log.line" ? ` content="${msg.line}"` : msg.type === "job.complete" ? ` status=${msg.status}` : "";
7348
+ const logDetail = msg.type === "log.line" ? debugStdioEnabled() ? ` content="${msg.line}"` : "" : msg.type === "job.complete" ? ` status=${msg.status}` : "";
7633
7349
  process.stderr.write(`[fork-runner] IPC message received: type=${msg.type}${logDetail}\n`);
7634
7350
  switch (msg.type) {
7635
7351
  case "ready":
@@ -7637,7 +7353,8 @@ function relayChildIpcMessage(msg, dispatch, ctx) {
7637
7353
  type: "execute",
7638
7354
  request: buildRequest(dispatch, ctx.effectiveWorkDir, {
7639
7355
  cleanupOnly: ctx.cleanupOnly,
7640
- credentialHelperPath: ctx.execOptions.credentialHelperPath
7356
+ credentialHelperPath: ctx.execOptions.credentialHelperPath,
7357
+ allowInstallScripts: ctx.execOptions.allowInstallScripts
7641
7358
  })
7642
7359
  });
7643
7360
  return;
@@ -7719,9 +7436,10 @@ function handleChildExitWithoutCompletion(code, signal, ctx) {
7719
7436
  process.stderr.write(`[fork-runner] Child exited: code=${code}, signal=${signal}, jobCompleted=${ctx.state.jobCompleted}\n`);
7720
7437
  if (ctx.state.jobCompleted) return;
7721
7438
  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})`;
7439
+ const stderrLines = ctx.stderrLines.map((line) => ctx.masker.mask(line));
7440
+ const crashMsg = isCancelled ? "Job cancelled" : stderrLines.length > 0 ? `Workflow runner crashed:\n${stderrLines.join("\n")}` : `Workflow runner exited unexpectedly (code=${code}, signal=${signal})`;
7723
7441
  if (!isCancelled) {
7724
- ctx.execOptions.onLogLine(-1, `[sandbox] Child exited: code=${code}, signal=${signal}, stderr=${ctx.stderrLines.slice(-5).join(" | ")}`);
7442
+ ctx.execOptions.onLogLine(-1, `[sandbox] Child exited: code=${code}, signal=${signal}, stderr=${stderrLines.slice(-5).join(" | ")}`);
7725
7443
  ctx.execOptions.onLogLine(-1, `[sandbox] ${crashMsg}`);
7726
7444
  }
7727
7445
  ctx.resolve({
@@ -7781,6 +7499,7 @@ function buildCancelFn(child, ctx, defaultGracePeriodMs, agentMaxGracePeriodMs,
7781
7499
  function createForkRunner(options, execOptions) {
7782
7500
  const sanitizedEnv = buildSanitizedEnv(options.env);
7783
7501
  if (options.useBwrap) sanitizedEnv.TMPDIR = "/tmp";
7502
+ if (debugStdioEnabled()) sanitizedEnv.KICI_RUNNER_DEBUG_STDIO = "true";
7784
7503
  const effectiveWorkDir = options.workDir ?? "/workspace";
7785
7504
  const dispatch = execOptions.dispatch;
7786
7505
  const { child, pidAssigned } = spawnRunnerChild(options, sanitizedEnv, effectiveWorkDir);
@@ -7828,6 +7547,10 @@ function createForkRunner(options, execOptions) {
7828
7547
  cancelTimers,
7829
7548
  state: sharedState,
7830
7549
  stderrLines,
7550
+ masker: createSecretMasker(buildRequest(dispatch, effectiveWorkDir, {
7551
+ cleanupOnly: options.cleanupOnly === true,
7552
+ credentialHelperPath: execOptions.credentialHelperPath
7553
+ })),
7831
7554
  cleanupOnly: options.cleanupOnly === true
7832
7555
  };
7833
7556
  process.stderr.write(`[fork-runner] Child process spawned: pid=${child.pid}, runner=${options.runnerPath}, cwd=${effectiveWorkDir}\n`);
@@ -7874,6 +7597,7 @@ function createForkRunner(options, execOptions) {
7874
7597
  };
7875
7598
  }
7876
7599
  var init_fork_runner = __esmMin((() => {
7600
+ init_log_masker();
7877
7601
  init_env_sanitizer();
7878
7602
  init_secret_encryption();
7879
7603
  }));
@@ -7895,16 +7619,17 @@ var init_fork_runner = __esmMin((() => {
7895
7619
  * network access. This mode provides credential isolation only and should
7896
7620
  * be used in trusted environments.
7897
7621
  */
7898
- var logger$5, BareMetalSandbox;
7622
+ var logger$6, BareMetalSandbox;
7899
7623
  var init_bare_metal_sandbox = __esmMin((() => {
7900
7624
  init_fork_runner();
7901
- logger$5 = createLogger({ prefix: "bare-metal-sandbox" });
7625
+ logger$6 = createLogger({ prefix: "bare-metal-sandbox" });
7902
7626
  BareMetalSandbox = class {
7903
7627
  runnerPath;
7904
7628
  useBwrap;
7905
7629
  sandboxNetwork;
7906
7630
  env;
7907
7631
  orphanCleanup;
7632
+ runnerUser;
7908
7633
  runner = null;
7909
7634
  workDir;
7910
7635
  lastOptions = null;
@@ -7914,6 +7639,7 @@ var init_bare_metal_sandbox = __esmMin((() => {
7914
7639
  this.sandboxNetwork = options.sandboxNetwork ?? "isolated";
7915
7640
  this.env = options.env;
7916
7641
  this.orphanCleanup = options.orphanCleanup ?? true;
7642
+ this.runnerUser = options.runnerUser;
7917
7643
  }
7918
7644
  /**
7919
7645
  * Validate that the runner path exists and bwrap is available (if needed).
@@ -7928,12 +7654,12 @@ var init_bare_metal_sandbox = __esmMin((() => {
7928
7654
  if (this.useBwrap) try {
7929
7655
  const { execSync } = await import("node:child_process");
7930
7656
  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)");
7657
+ if (this.sandboxNetwork === "isolated") logger$6.info("Bubblewrap (bwrap) sandbox enabled with network isolation (--unshare-net)");
7658
+ else logger$6.info("Bubblewrap (bwrap) sandbox enabled with host network (KICI_SANDBOX_NETWORK=host)");
7933
7659
  } catch {
7934
7660
  throw new Error("Bubblewrap (bwrap) not found. Install bubblewrap or set sandbox=false. On Debian/Ubuntu: apt install bubblewrap");
7935
7661
  }
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.");
7662
+ 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
7663
  }
7938
7664
  /**
7939
7665
  * Execute a job by forking the workflow runner with sanitized environment.
@@ -7948,7 +7674,8 @@ var init_bare_metal_sandbox = __esmMin((() => {
7948
7674
  workDir: this.workDir,
7949
7675
  networkIsolation: this.useBwrap && this.sandboxNetwork === "isolated",
7950
7676
  extraReadOnlyBinds,
7951
- detachProcessGroup: this.orphanCleanup && !this.useBwrap
7677
+ detachProcessGroup: this.orphanCleanup && !this.useBwrap,
7678
+ ...this.runnerUser ? { runnerUser: this.runnerUser } : {}
7952
7679
  }, options);
7953
7680
  return this.runner.result;
7954
7681
  }
@@ -7998,7 +7725,8 @@ var init_bare_metal_sandbox = __esmMin((() => {
7998
7725
  workDir,
7999
7726
  networkIsolation: this.useBwrap && this.sandboxNetwork === "isolated",
8000
7727
  cleanupOnly: true,
8001
- maxGracePeriodMs: 5e3
7728
+ maxGracePeriodMs: 5e3,
7729
+ ...this.runnerUser ? { runnerUser: this.runnerUser } : {}
8002
7730
  }, {
8003
7731
  ...opts,
8004
7732
  signal,
@@ -8164,6 +7892,7 @@ function buildContainerHardening(opts) {
8164
7892
  const network = grant?.network ?? opts.networkMode;
8165
7893
  if (network === "none") hostConfig.NetworkMode = "none";
8166
7894
  else if (network === "host") hostConfig.NetworkMode = "host";
7895
+ else if (opts.jobNetworkName) hostConfig.NetworkMode = opts.jobNetworkName;
8167
7896
  const user = grant?.user ?? opts.user;
8168
7897
  return user ? {
8169
7898
  hostConfig,
@@ -8176,6 +7905,233 @@ var init_container_hardening = __esmMin((() => {
8176
7905
  NO_NEW_PRIVILEGES = ["no-new-privileges"];
8177
7906
  }));
8178
7907
  //#endregion
7908
+ //#region src/execution/sandbox/job-network.ts
7909
+ /**
7910
+ * Resolve what a job container may reach on the host.
7911
+ *
7912
+ * The default is DNS on the bridge gateway and nothing else. A job container
7913
+ * talks to the agent through `docker exec` rather than over the network, and it
7914
+ * holds none of the agent's credentials, so name resolution is the one host
7915
+ * service it genuinely needs. That carve-out is not cosmetic: rootful podman
7916
+ * runs its resolver on the gateway, a host address, so a container with no such
7917
+ * rule cannot resolve any name at all. Docker answers inside the container's
7918
+ * own netns and is unaffected either way.
7919
+ *
7920
+ * The set is derived HERE, from the agent's own configuration, and takes no
7921
+ * argument that could widen it. A `NetworkPolicy` reaching
7922
+ * {@link applyJobEgressRules} governs the FORWARD hook only — what the job
7923
+ * reaches through the host — so its `hostAccess` is deliberately not consulted:
7924
+ * a job that could name its own host reachability is the escape hatch this
7925
+ * boundary exists to close, and a signature that cannot carry one is what makes
7926
+ * that structural rather than a convention.
7927
+ */
7928
+ function resolveJobHostAccess(gateway = JOB_NETWORK_GATEWAY) {
7929
+ return [`${gateway}:${DNS_PORT}`];
7930
+ }
7931
+ /**
7932
+ * Ensure the `kici-jobs` network exists, the nftables table is ready, and the
7933
+ * subnet's default egress drops are installed.
7934
+ *
7935
+ * The drops land here — before any job container is created — and are keyed on
7936
+ * the subnet rather than on a container address, so a customer image's own
7937
+ * `ENTRYPOINT` never runs unfiltered.
7938
+ *
7939
+ * Returns whether egress filtering is actually in force. A host without `nft`
7940
+ * or without `NET_ADMIN` — a rootless agent, most commonly — warns loudly and
7941
+ * degrades to the network with no rules rather than refusing to run any job:
7942
+ * `kici-admin agent install` is a compat-protected surface, and hard-requiring
7943
+ * NET_ADMIN would break every existing rootless install with no additive path.
7944
+ * The same trade-off the scaler already made for agent containers.
7945
+ */
7946
+ async function ensureJobNetwork(docker) {
7947
+ try {
7948
+ await validateNftablesAvailability();
7949
+ await ensureKiciTable();
7950
+ await ensureJobSubnetEgressRules(JOB_NETWORK_SUBNET, JOB_NETWORK_GATEWAY);
7951
+ const actual = await ensureJobNetworkExists(docker);
7952
+ if (actual.subnet !== JOB_NETWORK_SUBNET || actual.gateway !== "172.31.0.1") {
7953
+ 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.`);
7954
+ await ensureJobSubnetEgressRules(actual.subnet, actual.gateway);
7955
+ }
7956
+ return true;
7957
+ } catch (err) {
7958
+ 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.`);
7959
+ return false;
7960
+ }
7961
+ }
7962
+ /**
7963
+ * Install the default RFC1918 + cloud-metadata drops for the whole job subnet.
7964
+ *
7965
+ * Keyed on `ip saddr 172.31.0.0/16`, so one rule set covers every container
7966
+ * that will ever join the bridge — including one that does not exist yet. A
7967
+ * customer image's own `ENTRYPOINT` therefore starts behind the drops: there is
7968
+ * no window between the container running and its rules landing, because the
7969
+ * rules do not depend on the container.
7970
+ *
7971
+ * The per-container rules {@link applyJobEgressRules} adds stay the layer above
7972
+ * these, and stay effective: `addIsolationRules` inserts at the chain head, so
7973
+ * a rule keyed on one container's address is evaluated before the subnet set
7974
+ * and a per-job `accept` still wins over the subnet `drop`. The exception is a
7975
+ * repair (below), which puts a fresh subnet set at the head — above the
7976
+ * per-container rules of jobs that were already running. That reordering has no
7977
+ * effect while `applyJobEgressRules` is called with no policy, since the two
7978
+ * sets then hold identical verdicts; a per-job policy would have to reinstall
7979
+ * its own rules after a repair to keep winning.
7980
+ *
7981
+ * Idempotent, because `ensureJobNetwork` runs once per job and an unconditional
7982
+ * install would leak a rule set per job: the chain is read first and the install
7983
+ * is skipped when it already carries the set.
7984
+ *
7985
+ * The skip requires EVERY expected rule, never merely one of them. The install
7986
+ * is one `nft insert` per rule, so a failure or a kill part-way through
7987
+ * leaves some of the set in the chain; a check that skipped on the first match
7988
+ * would then read a chain missing the `10.0.0.0/8` drop as covered, and every
7989
+ * later job on that host would join the bridge behind permanently partial
7990
+ * filtering with nothing to heal it. Comparing against the rule text
7991
+ * {@link buildIsolationRuleOps} emits is also what rejects the two near-misses
7992
+ * the scaler writes into this same chain — a per-container rule at
7993
+ * `172.31.0.10`, and an operator allowlist naming this subnet as a DESTINATION.
7994
+ *
7995
+ * A partial set is repaired by installing the whole set again rather than by
7996
+ * deleting first: the fresh block lands at the chain head, complete and in
7997
+ * order, so a job container already running behind the surviving rules is never
7998
+ * left unfiltered even briefly. The superseded rules below it are inert
7999
+ * duplicates.
8000
+ */
8001
+ async function ensureJobSubnetEgressRules(subnet, gateway) {
8002
+ await ensureJobSubnetHostRules(subnet, gateway);
8003
+ const expected = buildIsolationRuleOps([
8004
+ "ip",
8005
+ "saddr",
8006
+ subnet
8007
+ ], gateway).map((tokens) => tokens.join(" "));
8008
+ const chain = await readForwardChain();
8009
+ const present = new Set((chain ?? "").split("\n").map((line) => line.trim().replace(/\s*# handle \d+$/, "")));
8010
+ if (chain !== null && expected.every((rule) => present.has(rule))) return;
8011
+ await addIsolationRules(subnet, gateway, void 0, "saddr");
8012
+ logger$5.info(`Installed default egress drops for ${subnet}`);
8013
+ }
8014
+ /**
8015
+ * Install the subnet-wide host-access rules — what a job container may reach ON
8016
+ * the host, as opposed to through it.
8017
+ *
8018
+ * Keyed on the subnet for the same reason the forward set is: it covers a
8019
+ * container that does not exist yet, so a customer image's `ENTRYPOINT` never
8020
+ * runs in the window before the per-container rules land.
8021
+ *
8022
+ * Idempotent against the chain's own text rather than by remove-then-insert.
8023
+ * Removing first would take the drop away from every running job container for
8024
+ * as long as the re-insert takes, which is the one direction that must not
8025
+ * fail open.
8026
+ */
8027
+ async function ensureJobSubnetHostRules(subnet, gateway) {
8028
+ const hostAccess = resolveJobHostAccess(gateway);
8029
+ const expected = buildHostAccessRuleOps([
8030
+ "ip",
8031
+ "saddr",
8032
+ subnet
8033
+ ], hostAccess).map((tokens) => tokens.join(" "));
8034
+ const chain = await readInputChain();
8035
+ const present = new Set((chain ?? "").split("\n").map((line) => line.trim().replace(/\s*# handle \d+$/, "")));
8036
+ if (chain !== null && expected.every((rule) => present.has(rule))) return;
8037
+ await addHostIsolationRules(subnet, hostAccess, "saddr");
8038
+ logger$5.info(`Installed default host-access rules for ${subnet}`);
8039
+ }
8040
+ /**
8041
+ * Create the `kici-jobs` bridge network, tolerating a concurrent creator.
8042
+ *
8043
+ * Returns the addressing job containers will actually get. That is the
8044
+ * canonical pair whenever this call created the network, and whatever an
8045
+ * already-present network carries otherwise — which is the only thing the drop
8046
+ * set can honestly be keyed on. A network someone else made under this name
8047
+ * with a different subnet would leave every job container outside a set keyed
8048
+ * on the constant, and the pre-start window this whole module exists to close
8049
+ * would reopen for it.
8050
+ */
8051
+ async function ensureJobNetworkExists(docker) {
8052
+ const canonical = {
8053
+ subnet: JOB_NETWORK_SUBNET,
8054
+ gateway: JOB_NETWORK_GATEWAY
8055
+ };
8056
+ const existing = await findJobNetwork(docker);
8057
+ if (existing) return existing;
8058
+ try {
8059
+ await docker.createNetwork({
8060
+ Name: JOB_NETWORK_NAME,
8061
+ Driver: "bridge",
8062
+ IPAM: { Config: [{
8063
+ Subnet: JOB_NETWORK_SUBNET,
8064
+ Gateway: JOB_NETWORK_GATEWAY
8065
+ }] },
8066
+ Labels: { "kici-managed": "true" }
8067
+ });
8068
+ logger$5.info(`Created job network ${JOB_NETWORK_NAME}`);
8069
+ return canonical;
8070
+ } catch (err) {
8071
+ if (err.statusCode === 409) return await findJobNetwork(docker) ?? canonical;
8072
+ throw err;
8073
+ }
8074
+ }
8075
+ /** The present `kici-jobs` network's addressing, or `undefined` when absent. */
8076
+ async function findJobNetwork(docker) {
8077
+ const found = (await docker.listNetworks({ filters: { name: [JOB_NETWORK_NAME] } })).find((n) => n.Name === JOB_NETWORK_NAME);
8078
+ if (!found) return void 0;
8079
+ const config = (found.IPAM?.Config ?? [])[0];
8080
+ return {
8081
+ subnet: config?.Subnet ?? JOB_NETWORK_SUBNET,
8082
+ gateway: config?.Gateway ?? "172.31.0.1"
8083
+ };
8084
+ }
8085
+ /**
8086
+ * Apply the per-container egress rules once the container has an IP.
8087
+ *
8088
+ * This is the per-job layer, not the control: the subnet drop set
8089
+ * {@link ensureJobNetwork} installs is already in the chain and already covers
8090
+ * this container. What a per-container rule set adds is a place to key a future
8091
+ * per-job policy — an allowlist or `denyAll` — on one address rather than on
8092
+ * the whole bridge.
8093
+ *
8094
+ * Returns the container IP the rules are keyed on, so teardown can remove
8095
+ * exactly those rules; `undefined` when no IP could be read, which is logged
8096
+ * rather than thrown — a job whose rules could not be keyed still runs behind
8097
+ * the subnet drops, and the operator sees why.
8098
+ */
8099
+ async function applyJobEgressRules(docker, containerId, policy) {
8100
+ const containerIp = (await docker.getContainer(containerId).inspect()).NetworkSettings?.Networks?.[JOB_NETWORK_NAME]?.IPAddress;
8101
+ if (!containerIp) {
8102
+ 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 });
8103
+ return;
8104
+ }
8105
+ await addIsolationRules(containerIp, JOB_NETWORK_GATEWAY, policy, "saddr");
8106
+ await addHostIsolationRules(containerIp, resolveJobHostAccess(), "saddr");
8107
+ return containerIp;
8108
+ }
8109
+ /**
8110
+ * Remove the per-container rules at teardown.
8111
+ *
8112
+ * Never throws: a failed cleanup must not turn a finished job into a failed
8113
+ * one. A leaked rule set is keyed on an IP the network will reuse, so it is
8114
+ * reported at `warn` for the operator rather than swallowed.
8115
+ */
8116
+ async function removeJobEgressRules(containerIp) {
8117
+ if (!containerIp) return;
8118
+ try {
8119
+ await removeIsolationRules(containerIp);
8120
+ } catch (err) {
8121
+ logger$5.warn("Failed to remove job-container egress rules", {
8122
+ containerIp,
8123
+ error: toErrorMessage(err)
8124
+ });
8125
+ }
8126
+ }
8127
+ var logger$5, JOB_NETWORK_NAME, JOB_NETWORK_GATEWAY, DNS_PORT;
8128
+ var init_job_network = __esmMin((() => {
8129
+ logger$5 = createLogger({ prefix: "job-network" });
8130
+ JOB_NETWORK_NAME = "kici-jobs";
8131
+ JOB_NETWORK_GATEWAY = "172.31.0.1";
8132
+ DNS_PORT = 53;
8133
+ }));
8134
+ //#endregion
8179
8135
  //#region src/execution/sandbox/container-sandbox.ts
8180
8136
  /**
8181
8137
  * Container execution sandbox implementation.
@@ -8421,6 +8377,7 @@ var init_container_sandbox = __esmMin((() => {
8421
8377
  init_image_preflight();
8422
8378
  init_secret_encryption();
8423
8379
  init_container_hardening();
8380
+ init_job_network();
8424
8381
  logger$4 = createLogger({ prefix: "container-sandbox" });
8425
8382
  MAX_STDERR_LINES = 20;
8426
8383
  ABORT_GRACE_MS = 1e4;
@@ -8449,6 +8406,9 @@ var init_container_sandbox = __esmMin((() => {
8449
8406
  keepFailed;
8450
8407
  jobId;
8451
8408
  hardening;
8409
+ networkIsolation;
8410
+ /** Container IP the applied egress rules are keyed on; unset when none were applied. */
8411
+ egressRuleIp;
8452
8412
  /** Resolved container user (image-user override / grant), applied to createContainer + each exec. */
8453
8413
  resolvedUser;
8454
8414
  /** The running container instance (set during setup). */
@@ -8473,6 +8433,7 @@ var init_container_sandbox = __esmMin((() => {
8473
8433
  this.keepFailed = options.keepFailed ?? false;
8474
8434
  this.jobId = options.jobId ?? `unknown-${Date.now()}`;
8475
8435
  this.hardening = options.hardening;
8436
+ this.networkIsolation = options.networkIsolation ?? true;
8476
8437
  }
8477
8438
  /**
8478
8439
  * Ensure the job's container image is present locally, pulling it on demand
@@ -8532,7 +8493,12 @@ var init_container_sandbox = __esmMin((() => {
8532
8493
  image: this.image,
8533
8494
  workDir: options.workDir
8534
8495
  });
8535
- const hardened = this.hardening ? buildContainerHardening(this.hardening) : {
8496
+ const effectiveNetwork = this.hardening?.grant?.network ?? this.hardening?.networkMode;
8497
+ const egressRulesActive = this.networkIsolation && effectiveNetwork !== "host" && effectiveNetwork !== "none" ? await ensureJobNetwork(this.docker) : false;
8498
+ const hardened = this.hardening ? buildContainerHardening({
8499
+ ...this.hardening,
8500
+ ...egressRulesActive ? { jobNetworkName: JOB_NETWORK_NAME } : {}
8501
+ }) : {
8536
8502
  hostConfig: {},
8537
8503
  user: void 0
8538
8504
  };
@@ -8559,6 +8525,7 @@ var init_container_sandbox = __esmMin((() => {
8559
8525
  }
8560
8526
  });
8561
8527
  await this.container.start();
8528
+ if (egressRulesActive) this.egressRuleIp = await applyJobEgressRules(this.docker, this.container.id, void 0);
8562
8529
  logger$4.info("Sandbox container started", {
8563
8530
  name: this.containerName,
8564
8531
  containerId: this.container.id.slice(0, 12)
@@ -8876,6 +8843,8 @@ var init_container_sandbox = __esmMin((() => {
8876
8843
  } catch {}
8877
8844
  }
8878
8845
  async teardown() {
8846
+ await removeJobEgressRules(this.egressRuleIp);
8847
+ this.egressRuleIp = void 0;
8879
8848
  if (!this.container) {
8880
8849
  await this.reclaimBuiltImage();
8881
8850
  return;
@@ -8965,85 +8934,7 @@ async function fileExists(p) {
8965
8934
  return false;
8966
8935
  }
8967
8936
  }
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
- }
8937
+ /** Agent-side fallbacks when the orchestrator sends no round budgets. */
9047
8938
  /**
9048
8939
  * Restore deps, materialize the workflow source, and install `.kici/`
9049
8940
  * dependencies for a dynamic-eval job — everything that has to exist before its
@@ -9054,7 +8945,24 @@ async function ensureFilterSourceDir(dispatch, workDir) {
9054
8945
  * handler's `logger.info` call site (which Loki keys off) or parameterize it,
9055
8946
  * and neither is worth it for twenty lines.
9056
8947
  */
9057
- async function materializeEvalWorkspace(dispatch, workDir, log) {
8948
+ /**
8949
+ * Sanitized base environment for a dependency install that runs in the AGENT
8950
+ * process rather than in a child.
8951
+ *
8952
+ * `installDeps` defaults `baseEnv` to `process.env`, which is correct inside the
8953
+ * runner child (already the sanitized job environment) and wrong here: the agent
8954
+ * process holds the orchestrator connection and, before boot scrubs them, the
8955
+ * agent's own credentials. `buildSanitizedEnv` merges its first argument
8956
+ * wholesale at its user-env layer, so it must be `{}` and never `process.env` —
8957
+ * passing `process.env` re-adds every variable the sanitizer just excluded.
8958
+ *
8959
+ * `trustedEnv` is threaded because it is an operator-set agent-launch property a
8960
+ * trusted fleet agent legitimately relies on.
8961
+ */
8962
+ function agentInstallBaseEnv(trustedEnv) {
8963
+ return buildSanitizedEnv({}, { trustedEnv });
8964
+ }
8965
+ async function materializeEvalWorkspace(dispatch, workDir, log, installOpts) {
9058
8966
  if (dispatch.depsUrl) {
9059
8967
  log("Restoring dependencies from cache");
9060
8968
  await restoreDeps(workDir, dispatch.depsUrl, dispatch.depsHash);
@@ -9062,7 +8970,7 @@ async function materializeEvalWorkspace(dispatch, workDir, log) {
9062
8970
  }
9063
8971
  if (dispatch.sourceTarUrl) {
9064
8972
  log("Restoring workflow source from cached tarball");
9065
- await restoreSource(workDir, dispatch.sourceTarUrl);
8973
+ await restoreSource(workDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
9066
8974
  } else {
9067
8975
  log(`Cloning ${dispatch.repoUrl} ref=${dispatch.ref}`);
9068
8976
  const cloneStart = Date.now();
@@ -9082,76 +8990,13 @@ async function materializeEvalWorkspace(dispatch, workDir, log) {
9082
8990
  await installDeps(kiciDir, {
9083
8991
  npmRegistries: dispatch.npmRegistries,
9084
8992
  installEnvSecrets: dispatch.installEnvSecrets,
9085
- jobIdShort: dispatch.jobId.slice(0, 8)
8993
+ jobIdShort: dispatch.jobId.slice(0, 8),
8994
+ baseEnv: installOpts.baseEnv,
8995
+ allowInstallScripts: installOpts.allowInstallScripts
9086
8996
  });
9087
8997
  }
9088
8998
  }
9089
8999
  /**
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
9000
  * Resolve the absolute path to the compiled workflow-runner.js entry point.
9156
9001
  *
9157
9002
  * The runner is a separate rolldown entry point. Its location depends on the
@@ -9173,6 +9018,25 @@ function resolveRunnerPath() {
9173
9018
  return bundlePath;
9174
9019
  }
9175
9020
  /**
9021
+ * Resolve the compiled eval-runner path, using the same three-location probe as
9022
+ * {@link resolveRunnerPath}.
9023
+ *
9024
+ * The eval child is always forked on the agent's own host from the agent's own
9025
+ * build — an evaluation job never goes through a sandbox backend — so unlike the
9026
+ * step runner it needs no self-contained bundle sibling to mount into a customer
9027
+ * job container.
9028
+ */
9029
+ function resolveEvalRunnerPath() {
9030
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9031
+ const bundlePath = join(__dirname, "eval-runner.js");
9032
+ if (existsSync(bundlePath)) return bundlePath;
9033
+ const originalPath = join(__dirname, "sandbox", "eval-runner.js");
9034
+ if (existsSync(originalPath)) return originalPath;
9035
+ const chunkedPath = join(__dirname, "execution", "sandbox", "eval-runner.js");
9036
+ if (existsSync(chunkedPath)) return chunkedPath;
9037
+ return bundlePath;
9038
+ }
9039
+ /**
9176
9040
  * Derive the self-contained runner bundle path from the resolved runner path.
9177
9041
  *
9178
9042
  * The container backend mounts the runner as a single file into the customer
@@ -9202,14 +9066,6 @@ function determineExecutionMode(jobConfig, agentConfig) {
9202
9066
  return "bare-metal";
9203
9067
  }
9204
9068
  /**
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
9069
  * Resolve a job's workDir and its cleanup.
9214
9070
  *
9215
9071
  * - Default: a fresh `mkdtemp` the agent clones into and removes after the job.
@@ -9236,26 +9092,20 @@ async function resolveJobWorkDir(inPlace, repoUrl) {
9236
9092
  inPlace: false
9237
9093
  };
9238
9094
  }
9239
- var logger$3, DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS, DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS, FILTER_SOURCE_DIRNAME, JobRunner$1;
9095
+ var logger$3, JobRunner$1;
9240
9096
  var init_job_runner = __esmMin((() => {
9241
9097
  init_git_clone();
9242
9098
  init_clone_job_repos();
9243
9099
  init_job_git_credentials();
9244
- init_changed_files();
9245
- init_workflow_loader();
9246
9100
  init_source_packer();
9247
9101
  init_source_restore();
9248
- init_init_runner();
9249
9102
  init_api_intercept();
9250
9103
  init_ensure_init_runner();
9251
9104
  init_payload_source();
9252
9105
  init_s3_payload_source();
9253
- init_timeout_util();
9254
- init_streaming_zx_log();
9255
9106
  init_dynamic_job_serializer();
9256
- init_generator_context();
9257
- init_global_workflow_env();
9258
- init_global_eval_runner();
9107
+ init_eval_fork_runner();
9108
+ init_eval_context();
9259
9109
  init_log_streamer();
9260
9110
  init_build_step();
9261
9111
  init_build_engine();
@@ -9267,9 +9117,6 @@ var init_job_runner = __esmMin((() => {
9267
9117
  init_sandbox();
9268
9118
  init_prometheus();
9269
9119
  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
9120
  JobRunner$1 = class {
9274
9121
  send;
9275
9122
  config;
@@ -9353,8 +9200,8 @@ var init_job_runner = __esmMin((() => {
9353
9200
  * orchestrator relay (unit harnesses, offline local runs).
9354
9201
  *
9355
9202
  * 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.
9203
+ * its own mechanisms exactly as it did before this existed. The job cannot
9204
+ * push.
9358
9205
  */
9359
9206
  async startGitCredentials(jobId, jobConfig) {
9360
9207
  if (!this._sendApiRequest) return void 0;
@@ -9667,6 +9514,7 @@ var init_job_runner = __esmMin((() => {
9667
9514
  }) : void 0,
9668
9515
  onGitGrantRequest: this.jobGitCredentials.get(jobId)?.onGitGrantRequest,
9669
9516
  credentialHelperPath: this.jobGitCredentials.get(jobId)?.helperPath,
9517
+ allowInstallScripts: this.config.allowInstallScripts,
9670
9518
  onCacheRequest: this._requestUserCache ? async (request) => this._requestUserCache(jobId, request) : void 0,
9671
9519
  onProvenanceRequest: this._relayProvenance ? async (request) => this._relayProvenance(jobId, request) : void 0,
9672
9520
  onArtifactRequest: this._requestUserArtifact ? async (request) => this._requestUserArtifact(jobId, request) : void 0,
@@ -9831,7 +9679,7 @@ var init_job_runner = __esmMin((() => {
9831
9679
  this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
9832
9680
  return;
9833
9681
  }
9834
- await this.packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog);
9682
+ await this.packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog, abortController.signal);
9835
9683
  buildLog("Build completed successfully");
9836
9684
  buildStreamer.flush();
9837
9685
  buildStreamer.destroy();
@@ -9914,7 +9762,7 @@ var init_job_runner = __esmMin((() => {
9914
9762
  await fsPromises.mkdir(workDir, { recursive: true });
9915
9763
  } else if (dispatch.sourceTarUrl) {
9916
9764
  initLog("Restoring workflow source from cached tarball");
9917
- await restoreSource(workDir, dispatch.sourceTarUrl);
9765
+ await restoreSource(workDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
9918
9766
  } else {
9919
9767
  initLog(`Cloning ${dispatch.repoUrl} (ref: ${dispatch.ref})`);
9920
9768
  const cloneStart = Date.now();
@@ -9955,13 +9803,16 @@ var init_job_runner = __esmMin((() => {
9955
9803
  await installDeps(kiciDir, {
9956
9804
  npmRegistries: dispatch.npmRegistries,
9957
9805
  installEnvSecrets: dispatch.installEnvSecrets,
9958
- jobIdShort: dispatch.jobId.slice(0, 8)
9806
+ jobIdShort: dispatch.jobId.slice(0, 8),
9807
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
9808
+ allowInstallScripts: this.config.allowInstallScripts
9959
9809
  });
9960
9810
  buildLog("Dependencies installed");
9961
9811
  if (!buildConfig.buildDepsNeeded) return;
9962
9812
  const { tarball, hash } = await packNodeModules(kiciDir);
9963
9813
  const depKey = {
9964
9814
  lockfileHash: buildConfig.lockfileHash,
9815
+ ...buildConfig.siblingsDigest && { siblingsDigest: buildConfig.siblingsDigest },
9965
9816
  platform: os.platform(),
9966
9817
  arch: os.arch()
9967
9818
  };
@@ -9992,16 +9843,28 @@ var init_job_runner = __esmMin((() => {
9992
9843
  * file's expected `contentHash`, pack `.kici/` into a tarball, and upload
9993
9844
  * it to the source cache.
9994
9845
  */
9995
- async packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog) {
9846
+ async packAndUploadSource(dispatch, workDir, buildConfig, buildStreamer, buildLog, signal) {
9996
9847
  if (!buildConfig.buildSourceNeeded || !buildConfig.contentHash) return;
9997
9848
  const sourceFile = dispatch.jobConfig.source?.file;
9998
9849
  if (!sourceFile) return;
9999
9850
  buildLog(`Verifying workflow source (${sourceFile})...`);
10000
- await runCaptured({ addLine: (line) => buildStreamer.addLine(line) }, () => loadWorkflowSource(workDir, sourceFile, buildConfig.contentHash, buildConfig.resolvedHashFiles));
9851
+ await this.runEvaluation({
9852
+ kind: "build-verify",
9853
+ dispatch,
9854
+ workDir,
9855
+ config: {
9856
+ sourceFile,
9857
+ contentHash: buildConfig.contentHash,
9858
+ resolvedHashFiles: buildConfig.resolvedHashFiles
9859
+ },
9860
+ emit: (line) => buildStreamer.addLine(line),
9861
+ signal
9862
+ });
10001
9863
  buildLog("Packing .kici/ source tarball...");
10002
9864
  const { tarball, hash: sourceTarHash } = await packKiciSource(workDir);
10003
9865
  const sourceKey = {
10004
9866
  contentHash: buildConfig.contentHash,
9867
+ sourceTarDigest: sourceTarHash,
10005
9868
  platform: os.platform(),
10006
9869
  arch: os.arch()
10007
9870
  };
@@ -10061,7 +9924,7 @@ var init_job_runner = __esmMin((() => {
10061
9924
  }
10062
9925
  if (dispatch.sourceTarUrl) {
10063
9926
  log("Restoring workflow source from cached tarball");
10064
- await restoreSource(workflowDir, dispatch.sourceTarUrl);
9927
+ await restoreSource(workflowDir, dispatch.sourceTarUrl, dispatch.sourceTarDigest);
10065
9928
  }
10066
9929
  const kiciDir = join(workflowDir, ".kici");
10067
9930
  if (!dispatch.depsUrl && await fileExists(join(kiciDir, "package.json"))) {
@@ -10069,7 +9932,9 @@ var init_job_runner = __esmMin((() => {
10069
9932
  await installDeps(kiciDir, {
10070
9933
  npmRegistries: dispatch.npmRegistries,
10071
9934
  installEnvSecrets: dispatch.installEnvSecrets,
10072
- jobIdShort: dispatch.jobId.slice(0, 8)
9935
+ jobIdShort: dispatch.jobId.slice(0, 8),
9936
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
9937
+ allowInstallScripts: this.config.allowInstallScripts
10073
9938
  });
10074
9939
  }
10075
9940
  }
@@ -10111,7 +9976,6 @@ var init_job_runner = __esmMin((() => {
10111
9976
  evalStreamer.destroy();
10112
9977
  streamerClosed = true;
10113
9978
  };
10114
- const evalSink = { addLine: (line) => evalLog(line) };
10115
9979
  this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.running);
10116
9980
  const heartbeatTimer = setInterval(() => {
10117
9981
  this.send({
@@ -10130,28 +9994,14 @@ var init_job_runner = __esmMin((() => {
10130
9994
  return;
10131
9995
  }
10132
9996
  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
- }));
9997
+ const roundResult = await this.runEvaluation({
9998
+ kind: "global-eval",
9999
+ dispatch,
10000
+ workDir,
10001
+ config,
10002
+ emit: (line, stream) => stream ? evalStreamLine(line, stream) : evalLog(line),
10003
+ signal: abortController.signal
10004
+ });
10155
10005
  const running = roundResult.candidates.filter((c) => c.run).length;
10156
10006
  const indeterminate = roundResult.candidates.filter((c) => c.indeterminate).length;
10157
10007
  logger$3.info("Global eval round completed", {
@@ -10186,6 +10036,48 @@ var init_job_runner = __esmMin((() => {
10186
10036
  }
10187
10037
  }
10188
10038
  /**
10039
+ * Run one evaluation in the eval child and stream its log onto the job's
10040
+ * synthetic step-0 log.
10041
+ *
10042
+ * Every customer-module load and evaluation goes through here. The calling
10043
+ * handler keeps what is the agent's own work — materializing the workspace,
10044
+ * installing dependencies, packing and uploading a tarball, reporting status —
10045
+ * and hands the child only the evaluation itself, in a process whose
10046
+ * environment carries no agent credential.
10047
+ */
10048
+ runEvaluation(args) {
10049
+ const { jobId } = args.dispatch;
10050
+ return runEvalChild({
10051
+ evalRunnerPath: resolveEvalRunnerPath(),
10052
+ request: {
10053
+ kind: args.kind,
10054
+ workDir: args.workDir,
10055
+ config: args.config,
10056
+ dispatch: {
10057
+ runId: args.dispatch.runId,
10058
+ jobId: args.dispatch.jobId,
10059
+ repoUrl: args.dispatch.repoUrl,
10060
+ ref: args.dispatch.ref,
10061
+ sha: args.dispatch.sha,
10062
+ token: args.dispatch.token,
10063
+ sourceAuth: args.dispatch.sourceAuth,
10064
+ workflowAuth: args.dispatch.workflowAuth,
10065
+ sourceTarUrl: args.dispatch.sourceTarUrl
10066
+ }
10067
+ },
10068
+ onLogLine: args.emit,
10069
+ ...this._sendApiRequest ? { onApiRequest: withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) } : {},
10070
+ trustedEnv: this.config.trustedEnv,
10071
+ signal: args.signal
10072
+ }).catch((err) => {
10073
+ logger$3.debug("Evaluation child reported a failure", {
10074
+ jobId,
10075
+ error: toErrorMessage(err)
10076
+ });
10077
+ throw err;
10078
+ });
10079
+ }
10080
+ /**
10189
10081
  * Handle an init-only job.
10190
10082
  *
10191
10083
  * Init jobs evaluate dynamic functions (environment, env, concurrencyGroup)
@@ -10220,7 +10112,6 @@ var init_job_runner = __esmMin((() => {
10220
10112
  await initStreamer.flush();
10221
10113
  initStreamer.destroy();
10222
10114
  };
10223
- const initSink = { addLine: (line) => initLog(line) };
10224
10115
  this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.running);
10225
10116
  const heartbeatTimer = setInterval(() => {
10226
10117
  this.send({
@@ -10254,21 +10145,18 @@ var init_job_runner = __esmMin((() => {
10254
10145
  await installDeps(kiciDir, {
10255
10146
  npmRegistries: dispatch.npmRegistries,
10256
10147
  installEnvSecrets: dispatch.installEnvSecrets,
10257
- jobIdShort: dispatch.jobId.slice(0, 8)
10148
+ jobIdShort: dispatch.jobId.slice(0, 8),
10149
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
10150
+ allowInstallScripts: this.config.allowInstallScripts
10258
10151
  });
10259
10152
  }
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);
10153
+ const initResult = await this.runEvaluation({
10154
+ kind: "init",
10155
+ dispatch,
10156
+ workDir,
10157
+ config,
10158
+ emit: (line, stream) => stream ? initStreamLine(line, stream) : initLog(line),
10159
+ signal: abortController.signal
10272
10160
  });
10273
10161
  logger$3.info("Init job completed successfully", {
10274
10162
  jobId,
@@ -10312,7 +10200,6 @@ var init_job_runner = __esmMin((() => {
10312
10200
  async handleDynamicJobFn(dispatch, workDir, abortController) {
10313
10201
  const { runId, jobId, jobConfig } = dispatch;
10314
10202
  const config = jobConfig;
10315
- const timeoutMs = config.timeoutMs ?? 12e4;
10316
10203
  logger$3.info("Starting DynamicJobFn evaluation", {
10317
10204
  jobId,
10318
10205
  workflowName: config.workflowName,
@@ -10336,47 +10223,17 @@ var init_job_runner = __esmMin((() => {
10336
10223
  this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
10337
10224
  return;
10338
10225
  }
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
- });
10226
+ await materializeEvalWorkspace(dispatch, workDir, evalLog, {
10227
+ baseEnv: agentInstallBaseEnv(this.config.trustedEnv),
10228
+ allowInstallScripts: this.config.allowInstallScripts
10229
+ });
10230
+ const lockJobs = await this.runEvaluation({
10231
+ kind: "dynamic-job",
10232
+ dispatch,
10233
+ workDir,
10234
+ config,
10235
+ emit: (line, stream) => evalStreamer.addLine(line, stream),
10236
+ signal: abortController.signal
10380
10237
  });
10381
10238
  logger$3.info("DynamicJobFn evaluation completed", {
10382
10239
  jobId,
@@ -10478,6 +10335,7 @@ var init_job_runner = __esmMin((() => {
10478
10335
  networkMode: this.config.sandboxNetwork === "host" ? "host" : "default",
10479
10336
  grant: opts.jobConfig.sandboxGrant
10480
10337
  },
10338
+ networkIsolation: this.config.sandboxNetworkIsolation,
10481
10339
  ...opts.registryAuth ? { registryAuth: opts.registryAuth } : {},
10482
10340
  ...opts.builtImage ? { buildTag: opts.builtImage } : {},
10483
10341
  ...this.config.runtimeNodeSource ? { runtimeNodePath: this.config.runtimeNodeSource } : {},
@@ -10493,7 +10351,8 @@ var init_job_runner = __esmMin((() => {
10493
10351
  env: opts.env,
10494
10352
  sandbox: this.config.sandbox,
10495
10353
  sandboxNetwork: this.config.sandboxNetwork,
10496
- orphanCleanup: this.config.orphanCleanup
10354
+ orphanCleanup: this.config.orphanCleanup,
10355
+ ...this.config.runnerUser ? { runnerUser: this.config.runnerUser } : {}
10497
10356
  });
10498
10357
  }
10499
10358
  }
@@ -10797,16 +10656,15 @@ var init_between_jobs_controller = __esmMin((() => {
10797
10656
  * - SIGTERM/SIGINT: 10s grace for running jobs, then force-kill
10798
10657
  * - SIGUSR1: Drain mode (stop accepting, finish current, exit)
10799
10658
  */
10800
- init_console_capture();
10801
10659
  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";
10660
+ const AGENT_VERSION = "0.7.0";
10661
+ const BUILD_COMMIT = "31843a21c";
10662
+ const SDK_VERSION = "0.7.0";
10663
+ const SDK_BUNDLE_HASH = "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
10664
+ const SHARED_VERSION = "0.7.0";
10807
10665
  const SHARED_BUNDLE_HASH = "a79be949815735b9e36eecc716f7eb07aa36c7bffba62e798e7e19f31a474eff";
10808
- const ENGINE_VERSION = "0.6.1";
10809
- const ENGINE_BUNDLE_HASH = "e49533b19f122155b3a78d95d105f97da259c2a45dee579578324b0702c91708";
10666
+ const ENGINE_VERSION = "0.7.0";
10667
+ const ENGINE_BUNDLE_HASH = "380f6edf7e0e5cfe988fa0f98512de6e6864f4f2a7a8d07f67e2ab84ed637d19";
10810
10668
  initTelemetry({
10811
10669
  serviceName: "kici-agent",
10812
10670
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
@@ -10845,7 +10703,11 @@ async function serializeAgentMetrics() {
10845
10703
  installConsoleCapture();
10846
10704
  await guardStartup(logger$1, async () => {
10847
10705
  const config = loadConfig();
10706
+ const scrubbedEnvVars = scrubCredentialEnv();
10707
+ const exposedPosture = describeExposedPosture(config);
10708
+ if (exposedPosture) logger$1.warn(exposedPosture);
10848
10709
  logger$1.info("Agent starting", {
10710
+ scrubbedEnvVars,
10849
10711
  agentId: config.agentId,
10850
10712
  orchestratorUrl: config.orchestratorUrl,
10851
10713
  labels: config.labels,