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