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