@kici-dev/agent 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +38 -38
- package/dist/eval-runner.js +1866 -0
- package/dist/execution/dep-installer.d.ts +28 -7
- package/dist/execution/eval-context.d.ts +114 -0
- package/dist/execution/global-eval-types.d.ts +26 -0
- package/dist/execution/job-runner.d.ts +23 -75
- package/dist/execution/npm-registry-config.d.ts +6 -0
- package/dist/execution/rule-evaluator.d.ts +2 -1
- package/dist/execution/sandbox/bare-metal-sandbox.d.ts +6 -0
- package/dist/execution/sandbox/container-hardening.d.ts +8 -0
- package/dist/execution/sandbox/container-sandbox.d.ts +9 -0
- package/dist/execution/sandbox/eval-dispatch.d.ts +28 -0
- package/dist/execution/sandbox/eval-fork-runner.d.ts +43 -0
- package/dist/execution/sandbox/eval-runner.d.ts +21 -0
- package/dist/execution/sandbox/fork-runner.d.ts +23 -0
- package/dist/execution/sandbox/ipc-protocol.d.ts +82 -8
- package/dist/execution/sandbox/job-network.d.ts +91 -0
- package/dist/execution/sandbox/log-masker.d.ts +38 -0
- package/dist/execution/sandbox/types.d.ts +6 -0
- package/dist/execution/sandbox/workflow-runner.d.ts +1 -1
- package/dist/execution/source-packer.d.ts +4 -4
- package/dist/execution/source-restore.d.ts +28 -13
- package/dist/execution/workflow-loader.d.ts +16 -13
- package/dist/execution/yarnrc-berry-config.d.ts +6 -4
- package/dist/index.js +83 -40
- package/dist/provenance/statement-builder.d.ts +19 -8
- package/dist/server.js +1530 -1666
- package/dist/workflow-runner-bundle.js +1129 -192
- package/dist/workflow-runner.js +395 -149
- package/dist/ws/orchestrator-client.d.ts +4 -0
- package/package.json +6 -5
- package/sbom.spdx.json +66 -66
package/dist/workflow-runner.js
CHANGED
|
@@ -10,7 +10,8 @@ import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256,
|
|
|
10
10
|
import { createTempScope, makeTempDir } from "@kici-dev/core/tmp";
|
|
11
11
|
import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName, reservedEventNamePrefix } from "@kici-dev/engine";
|
|
12
12
|
import { execFile, execFileSync } from "node:child_process";
|
|
13
|
-
import {
|
|
13
|
+
import { isDynamicJobFn, isEventDefinition, isParallelGroup, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs } from "@kici-dev/sdk";
|
|
14
|
+
import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, normalizeApproval, normalizeCacheSpecs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk/internal";
|
|
14
15
|
import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
|
|
15
16
|
import { HOME_ANCHOR, REPO_ANCHOR, computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
|
|
16
17
|
import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
|
|
@@ -30,6 +31,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
30
31
|
import { promisify } from "node:util";
|
|
31
32
|
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
|
|
32
33
|
import { parse, stringify } from "yaml";
|
|
34
|
+
import { COMPILE_SCHEMA_VERSION, collectSourceSymlinks, findKiciDir, hashKiciSourceTree, hashedSymlinkDriftNote } from "@kici-dev/core/kici-source-digest";
|
|
33
35
|
var __defProp = Object.defineProperty;
|
|
34
36
|
var __esmMin = (fn, res, err) => () => {
|
|
35
37
|
if (err) throw err[0];
|
|
@@ -359,7 +361,12 @@ async function computeChangedFiles(workDir, event, auth) {
|
|
|
359
361
|
* for a deferred attestation (no minted identity token yet). Marks
|
|
360
362
|
* `attestationOrigin: 'deferred'` in the internal parameters. The caller
|
|
361
363
|
* DSSE-signs the returned statement immediately and computes its statement hash
|
|
362
|
-
* — the
|
|
364
|
+
* — one of the two bindings the later OIDC mint commits to.
|
|
365
|
+
*
|
|
366
|
+
* Emits the same fields `buildProvenanceStatement` emits, so a statement frozen
|
|
367
|
+
* from an orchestrator-supplied context is field-for-field what a live mint
|
|
368
|
+
* would have produced. That is what lets the orchestrator cross-check the
|
|
369
|
+
* statement against its own run row before signing anything that commits to it.
|
|
363
370
|
*/
|
|
364
371
|
function buildLocalProvenanceStatement(input) {
|
|
365
372
|
const c = input.context;
|
|
@@ -373,11 +380,14 @@ function buildLocalProvenanceStatement(input) {
|
|
|
373
380
|
predicate: {
|
|
374
381
|
buildDefinition: {
|
|
375
382
|
buildType: KICI_WORKFLOW_BUILD_TYPE,
|
|
376
|
-
externalParameters: {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
383
|
+
externalParameters: {
|
|
384
|
+
workflow: {
|
|
385
|
+
repository: c.repository,
|
|
386
|
+
ref: c.ref,
|
|
387
|
+
path: c.workflowRef
|
|
388
|
+
},
|
|
389
|
+
...c.provider ? { provider: c.provider } : {}
|
|
390
|
+
},
|
|
381
391
|
internalParameters: {
|
|
382
392
|
...c.sha ? { commit: c.sha } : {},
|
|
383
393
|
runId: c.runId,
|
|
@@ -389,7 +399,7 @@ function buildLocalProvenanceStatement(input) {
|
|
|
389
399
|
},
|
|
390
400
|
runDetails: {
|
|
391
401
|
builder: {
|
|
392
|
-
id: `${c.issuer}/orchestrator
|
|
402
|
+
id: `${c.issuer}/orchestrator/${c.orchestratorId ?? "unknown"}`,
|
|
393
403
|
version: input.builderVersions
|
|
394
404
|
},
|
|
395
405
|
metadata: {
|
|
@@ -1384,15 +1394,6 @@ function createArtifactsApi(workDir, transport, roots) {
|
|
|
1384
1394
|
}
|
|
1385
1395
|
//#endregion
|
|
1386
1396
|
//#region src/execution/sandbox/log-masker.ts
|
|
1387
|
-
/**
|
|
1388
|
-
* Secret value masking for log lines.
|
|
1389
|
-
*
|
|
1390
|
-
* Replaces all occurrences of registered secret values with '***' in log output.
|
|
1391
|
-
* Used by the workflow runner to prevent secret leaks in IPC log messages.
|
|
1392
|
-
*
|
|
1393
|
-
* Performance: Builds a single combined regex from all secret values, so each
|
|
1394
|
-
* log line is scanned in a single pass (not O(secrets * lines)).
|
|
1395
|
-
*/
|
|
1396
1397
|
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
1397
1398
|
const MIN_MASK_LENGTH = 3;
|
|
1398
1399
|
/**
|
|
@@ -1423,18 +1424,38 @@ var LogMasker = class {
|
|
|
1423
1424
|
* Authorization: Basic headers, base64-encoded config values).
|
|
1424
1425
|
* Values are sorted by length descending so longer values are matched first
|
|
1425
1426
|
* (prevents partial masking when one secret is a substring of another).
|
|
1427
|
+
*
|
|
1428
|
+
* Multi-line values additionally register each of their individual lines.
|
|
1429
|
+
* Log output is split into lines before it reaches the masker, so a value
|
|
1430
|
+
* containing a newline can never match as a whole — a PEM private key or a
|
|
1431
|
+
* kubeconfig would otherwise stream in clear text. Two consequences of the
|
|
1432
|
+
* per-line registration are deliberate:
|
|
1433
|
+
*
|
|
1434
|
+
* - `MIN_MASK_LENGTH` is 3, so short structural lines of a structured secret
|
|
1435
|
+
* are registered too. A `---` YAML separator or a bare `{` from a
|
|
1436
|
+
* service-account JSON is masked wherever it appears in that job's logs.
|
|
1437
|
+
* - PEM header and footer lines (`-----BEGIN OPENSSH PRIVATE KEY-----`) are
|
|
1438
|
+
* not secret on their own and are masked as a side effect.
|
|
1439
|
+
*
|
|
1440
|
+
* Both are strictly safer than leaking the body, and no heuristic separates a
|
|
1441
|
+
* structural line from a body line without risking the reverse mistake.
|
|
1426
1442
|
*/
|
|
1427
1443
|
registerSecrets(secrets) {
|
|
1428
1444
|
const seen = /* @__PURE__ */ new Set();
|
|
1429
1445
|
const values = [];
|
|
1430
|
-
|
|
1431
|
-
seen.
|
|
1432
|
-
|
|
1433
|
-
|
|
1446
|
+
const add = (candidate) => {
|
|
1447
|
+
if (candidate.length < MIN_MASK_LENGTH || seen.has(candidate)) return;
|
|
1448
|
+
seen.add(candidate);
|
|
1449
|
+
values.push(candidate);
|
|
1450
|
+
const b64 = Buffer.from(candidate).toString("base64");
|
|
1434
1451
|
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
1435
1452
|
seen.add(b64);
|
|
1436
1453
|
values.push(b64);
|
|
1437
1454
|
}
|
|
1455
|
+
};
|
|
1456
|
+
for (const value of Object.values(secrets)) {
|
|
1457
|
+
add(value);
|
|
1458
|
+
if (value.includes("\n")) for (const rawLine of value.split("\n")) add(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
|
|
1438
1459
|
}
|
|
1439
1460
|
if (values.length === 0) {
|
|
1440
1461
|
this.pattern = null;
|
|
@@ -1460,6 +1481,57 @@ var LogMasker = class {
|
|
|
1460
1481
|
return this.pattern !== null;
|
|
1461
1482
|
}
|
|
1462
1483
|
};
|
|
1484
|
+
/**
|
|
1485
|
+
* Create a LogMasker initialized with all secret values from the request.
|
|
1486
|
+
*
|
|
1487
|
+
* Collects values from both flat secrets and all namespaced context secrets,
|
|
1488
|
+
* deduplicating before registration.
|
|
1489
|
+
*
|
|
1490
|
+
* Both the runner child and the agent-side fork runner build a masker from the
|
|
1491
|
+
* same request, so the crash tail the agent assembles from the child's stderr is
|
|
1492
|
+
* masked with the same value set the child used for its own log lines.
|
|
1493
|
+
*/
|
|
1494
|
+
function createSecretMasker(request) {
|
|
1495
|
+
const masker = new LogMasker();
|
|
1496
|
+
const allSecrets = {};
|
|
1497
|
+
if (request.secrets) Object.assign(allSecrets, request.secrets);
|
|
1498
|
+
if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
|
|
1499
|
+
masker.registerSecrets(allSecrets);
|
|
1500
|
+
return masker;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Mask every operator-visible text field of an outbound runner message.
|
|
1504
|
+
*
|
|
1505
|
+
* Each message type carrying free text is named here, so a new text-bearing
|
|
1506
|
+
* message type is a visible omission rather than a silent leak. `step.complete`
|
|
1507
|
+
* error text and the `job.complete` failure reason are persisted on the step and
|
|
1508
|
+
* run rows the dashboard renders, so they need the same masking `log.line` gets.
|
|
1509
|
+
*
|
|
1510
|
+
* Returns the message unchanged when no secrets are registered.
|
|
1511
|
+
*/
|
|
1512
|
+
function maskMessageText(msg, masker) {
|
|
1513
|
+
if (!masker.hasSecrets()) return msg;
|
|
1514
|
+
switch (msg.type) {
|
|
1515
|
+
case "log.line": return {
|
|
1516
|
+
...msg,
|
|
1517
|
+
line: masker.mask(msg.line)
|
|
1518
|
+
};
|
|
1519
|
+
case "step.complete": return msg.error ? {
|
|
1520
|
+
...msg,
|
|
1521
|
+
error: {
|
|
1522
|
+
...msg.error,
|
|
1523
|
+
message: masker.mask(msg.error.message)
|
|
1524
|
+
}
|
|
1525
|
+
} : msg;
|
|
1526
|
+
case "job.complete": {
|
|
1527
|
+
const masked = { ...msg };
|
|
1528
|
+
if (masked.error !== void 0) masked.error = masker.mask(masked.error);
|
|
1529
|
+
if (masked.droppedJobs) masked.droppedJobs = masked.droppedJobs.map((j) => masker.mask(j));
|
|
1530
|
+
return masked;
|
|
1531
|
+
}
|
|
1532
|
+
default: return msg;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1463
1535
|
//#endregion
|
|
1464
1536
|
//#region src/execution/sandbox/env-delta.ts
|
|
1465
1537
|
/**
|
|
@@ -1639,18 +1711,23 @@ async function executeHook(opts) {
|
|
|
1639
1711
|
step_type: `hook:${hookType}`
|
|
1640
1712
|
});
|
|
1641
1713
|
const startTime = Date.now();
|
|
1714
|
+
const abortController = new AbortController();
|
|
1715
|
+
let rejectTimeout = () => {};
|
|
1716
|
+
const timeoutRace = new Promise((_, reject) => {
|
|
1717
|
+
rejectTimeout = reject;
|
|
1718
|
+
});
|
|
1719
|
+
const timeoutId = setTimeout(() => {
|
|
1720
|
+
rejectTimeout(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
|
|
1721
|
+
abortController.abort();
|
|
1722
|
+
}, timeoutMs);
|
|
1642
1723
|
const mergedCtx = {
|
|
1643
1724
|
...stepContext,
|
|
1644
|
-
outcome
|
|
1725
|
+
outcome,
|
|
1726
|
+
signal: AbortSignal.any([...stepContext.signal ? [stepContext.signal] : [], abortController.signal]),
|
|
1727
|
+
...typeof stepContext.$ === "function" ? { $: stepContext.$({ signal: abortController.signal }) } : {}
|
|
1645
1728
|
};
|
|
1646
|
-
const abortController = new AbortController();
|
|
1647
|
-
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
1648
1729
|
try {
|
|
1649
|
-
await Promise.race([normalized.run(mergedCtx),
|
|
1650
|
-
abortController.signal.addEventListener("abort", () => {
|
|
1651
|
-
reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
|
|
1652
|
-
});
|
|
1653
|
-
})]);
|
|
1730
|
+
await Promise.race([normalized.run(mergedCtx), timeoutRace]);
|
|
1654
1731
|
clearTimeout(timeoutId);
|
|
1655
1732
|
sendIpc({
|
|
1656
1733
|
type: "step.complete",
|
|
@@ -1925,12 +2002,12 @@ const STEP_FAILURE_LOG_MAX_CHARS = 8192;
|
|
|
1925
2002
|
* One consequence to know: a `$({ quiet: true })` command's output is kept out
|
|
1926
2003
|
* of the log by the `verbose` gate in `streaming-zx-log.ts`, but when such a
|
|
1927
2004
|
* command FAILS, zx packs its captured output into the thrown error's message —
|
|
1928
|
-
* which this function then writes to the log.
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
* has never been told about is not.
|
|
2005
|
+
* which this function then writes to the log. Surfacing it is the whole point:
|
|
2006
|
+
* a quiet command that fails is exactly the failure an operator cannot
|
|
2007
|
+
* otherwise diagnose. The same text also travels on `step.complete` for the step
|
|
2008
|
+
* row the dashboard renders, and both copies pass the masker — `maskMessageText`
|
|
2009
|
+
* masks `step.complete.error.message` alongside `log.line`. Registered secret
|
|
2010
|
+
* values are masked; anything the masker has never been told about is not.
|
|
1934
2011
|
*/
|
|
1935
2012
|
function emitStepFailureLog(stepName, stepIndex, message, sendFn) {
|
|
1936
2013
|
const prefix = `[kici] Step '${stepName}' failed: `;
|
|
@@ -1967,16 +2044,29 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
1967
2044
|
});
|
|
1968
2045
|
const startTime = Date.now();
|
|
1969
2046
|
const abortController = new AbortController();
|
|
1970
|
-
|
|
2047
|
+
let timedOut = false;
|
|
2048
|
+
let rejectTimeout = () => {};
|
|
2049
|
+
const timeoutRace = new Promise((_, reject) => {
|
|
2050
|
+
rejectTimeout = reject;
|
|
2051
|
+
});
|
|
2052
|
+
const timeoutId = setTimeout(() => {
|
|
2053
|
+
timedOut = true;
|
|
2054
|
+
rejectTimeout(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
|
|
2055
|
+
abortController.abort();
|
|
2056
|
+
opts.abortStep?.(stepIndex);
|
|
2057
|
+
}, timeoutMs);
|
|
1971
2058
|
const stepAbortSignal = opts.getStepAbortSignal?.(stepIndex);
|
|
2059
|
+
const stepPromise = runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts);
|
|
2060
|
+
let stepSettled = false;
|
|
2061
|
+
const stepSettledPromise = stepPromise.then(() => {
|
|
2062
|
+
stepSettled = true;
|
|
2063
|
+
}, () => {
|
|
2064
|
+
stepSettled = true;
|
|
2065
|
+
});
|
|
1972
2066
|
try {
|
|
1973
2067
|
const phase = await Promise.race([
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
abortController.signal.addEventListener("abort", () => {
|
|
1977
|
-
reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
|
|
1978
|
-
});
|
|
1979
|
-
}),
|
|
2068
|
+
stepPromise,
|
|
2069
|
+
timeoutRace,
|
|
1980
2070
|
new Promise((_, reject) => {
|
|
1981
2071
|
if (!jobDeadlineSignal) return;
|
|
1982
2072
|
if (jobDeadlineSignal.aborted) {
|
|
@@ -1993,7 +2083,10 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
1993
2083
|
reject(new StepCancelledError(step.name));
|
|
1994
2084
|
return;
|
|
1995
2085
|
}
|
|
1996
|
-
stepAbortSignal.addEventListener("abort", () =>
|
|
2086
|
+
stepAbortSignal.addEventListener("abort", () => {
|
|
2087
|
+
if (timedOut) return;
|
|
2088
|
+
reject(new StepCancelledError(step.name));
|
|
2089
|
+
});
|
|
1997
2090
|
})
|
|
1998
2091
|
]);
|
|
1999
2092
|
clearTimeout(timeoutId);
|
|
@@ -2025,6 +2118,15 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
2025
2118
|
clearTimeout(timeoutId);
|
|
2026
2119
|
const durationMs = Date.now() - startTime;
|
|
2027
2120
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
2121
|
+
if (timedOut && !stepSettled) {
|
|
2122
|
+
await Promise.race([stepSettledPromise, delayUnref(STEP_ABORT_GRACE_MS)]);
|
|
2123
|
+
if (!stepSettled) sendFn({
|
|
2124
|
+
type: "log.line",
|
|
2125
|
+
stepIndex,
|
|
2126
|
+
line: `[timeout] Step '${step.name}' did not stop within ${STEP_ABORT_GRACE_MS}ms of its abort signal; continuing without it.`,
|
|
2127
|
+
stream: LogStream.enum.stderr
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2028
2130
|
if (e instanceof StepCancelledError) {
|
|
2029
2131
|
const secretsAccessed = getSecretsAccessLog?.(stepIndex);
|
|
2030
2132
|
emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
|
|
@@ -2073,6 +2175,17 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
2073
2175
|
}
|
|
2074
2176
|
}
|
|
2075
2177
|
/**
|
|
2178
|
+
* How long a timed-out step is given to unwind after its abort signal fires,
|
|
2179
|
+
* before the loop reports that the step ignored it and moves on.
|
|
2180
|
+
*/
|
|
2181
|
+
const STEP_ABORT_GRACE_MS = 2e3;
|
|
2182
|
+
/** A timer-backed delay that never keeps the process alive on its own. */
|
|
2183
|
+
function delayUnref(ms) {
|
|
2184
|
+
return new Promise((resolve) => {
|
|
2185
|
+
setTimeout(resolve, ms).unref?.();
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2076
2189
|
* Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
|
|
2077
2190
|
* the step performed. Called from both the success and failure paths so the
|
|
2078
2191
|
* orchestrator's audit trail records every mount regardless of step outcome.
|
|
@@ -3365,10 +3478,12 @@ function redactNpmOutput(input, tokens) {
|
|
|
3365
3478
|
*
|
|
3366
3479
|
* `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
|
|
3367
3480
|
* (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
|
|
3368
|
-
* workflow-loader work unchanged. `enableScripts: false`
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3371
|
-
* `--ignore-scripts`.
|
|
3481
|
+
* workflow-loader work unchanged. `enableScripts: false` keeps dependency
|
|
3482
|
+
* lifecycle scripts from running at all — for every install, not only one
|
|
3483
|
+
* against a private registry — the same security model as npm/pnpm/classic
|
|
3484
|
+
* `--ignore-scripts`. An operator opts back in with
|
|
3485
|
+
* `KICI_ALLOW_INSTALL_SCRIPTS=true`, which arrives here as
|
|
3486
|
+
* `ignoreScripts: false`.
|
|
3372
3487
|
*
|
|
3373
3488
|
* Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
|
|
3374
3489
|
* shapes as the npm overlay so `dep-installer` can pick either by flavor.
|
|
@@ -3415,8 +3530,8 @@ async function applyYarnrcBerryConfig(args) {
|
|
|
3415
3530
|
};
|
|
3416
3531
|
const tokenEnv = {};
|
|
3417
3532
|
const tokensForRedaction = [];
|
|
3533
|
+
if (args.ignoreScripts !== false) merged.enableScripts = false;
|
|
3418
3534
|
if (hasPrivateRegistry) {
|
|
3419
|
-
merged.enableScripts = false;
|
|
3420
3535
|
const npmScopes = { ...doc.npmScopes ?? {} };
|
|
3421
3536
|
for (let i = 0; i < registries.length; i++) {
|
|
3422
3537
|
const reg = registries[i];
|
|
@@ -3725,9 +3840,11 @@ function isAbsoluteRel(rel) {
|
|
|
3725
3840
|
* Security: the install runs with an isolated per-invocation cache/store
|
|
3726
3841
|
* directory to prevent cache poisoning across build jobs — a malicious
|
|
3727
3842
|
* package.json in one repo cannot taint the cache used by subsequent builds.
|
|
3728
|
-
* The
|
|
3729
|
-
*
|
|
3730
|
-
*
|
|
3843
|
+
* The install runs with `--ignore-scripts` for every package manager. A
|
|
3844
|
+
* lifecycle script in a committed `package.json` is customer code the agent
|
|
3845
|
+
* never agreed to execute: it would run wherever the install runs, which for a
|
|
3846
|
+
* step-child install is the process holding the job's secrets. Operators who
|
|
3847
|
+
* genuinely need it set `KICI_ALLOW_INSTALL_SCRIPTS=true` on the agent.
|
|
3731
3848
|
*/
|
|
3732
3849
|
const logger$2 = createLogger({ prefix: "dep-installer" });
|
|
3733
3850
|
const execFileAsync = promisify(execFile);
|
|
@@ -3763,9 +3880,11 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
|
|
|
3763
3880
|
* between build jobs; the directory is removed after installation.
|
|
3764
3881
|
*
|
|
3765
3882
|
* If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
|
|
3766
|
-
* `.kici/.npmrc` overlay is synthesized for the install
|
|
3767
|
-
*
|
|
3768
|
-
*
|
|
3883
|
+
* `.kici/.npmrc` overlay is synthesized for the install and restored in
|
|
3884
|
+
* `finally`.
|
|
3885
|
+
*
|
|
3886
|
+
* Lifecycle scripts are disabled for every package manager unless the operator
|
|
3887
|
+
* set `opts.allowInstallScripts`.
|
|
3769
3888
|
*
|
|
3770
3889
|
* @param kiciDir - Path to the `.kici/` directory containing package.json.
|
|
3771
3890
|
* @param opts - Optional registry / installEnv / repoRoot configuration.
|
|
@@ -3787,13 +3906,15 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
3787
3906
|
yarnFlavor
|
|
3788
3907
|
});
|
|
3789
3908
|
const startTime = Date.now();
|
|
3790
|
-
const
|
|
3909
|
+
const ignoreScripts = opts.allowInstallScripts !== true;
|
|
3910
|
+
const baseEnv = opts.baseEnv ?? process.env;
|
|
3791
3911
|
const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
|
|
3792
3912
|
const registryConfig = isBerry ? await applyYarnrcBerryConfig({
|
|
3793
3913
|
kiciDir,
|
|
3794
3914
|
npmRegistries: opts.npmRegistries,
|
|
3795
3915
|
installEnvSecrets: opts.installEnvSecrets,
|
|
3796
|
-
jobIdShort: opts.jobIdShort ?? "00000000"
|
|
3916
|
+
jobIdShort: opts.jobIdShort ?? "00000000",
|
|
3917
|
+
ignoreScripts
|
|
3797
3918
|
}) : await applyNpmRegistryConfig({
|
|
3798
3919
|
kiciDir,
|
|
3799
3920
|
npmRegistries: opts.npmRegistries,
|
|
@@ -3803,22 +3924,26 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
3803
3924
|
try {
|
|
3804
3925
|
if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
|
|
3805
3926
|
kiciDir,
|
|
3806
|
-
|
|
3807
|
-
registryConfig
|
|
3927
|
+
ignoreScripts,
|
|
3928
|
+
registryConfig,
|
|
3929
|
+
baseEnv
|
|
3808
3930
|
});
|
|
3809
3931
|
else if (isBerry) await runYarnBerryInstall({
|
|
3810
3932
|
kiciDir,
|
|
3811
|
-
registryConfig
|
|
3933
|
+
registryConfig,
|
|
3934
|
+
baseEnv
|
|
3812
3935
|
});
|
|
3813
3936
|
else if (packageManager === PackageManager.Yarn) await runYarnInstall({
|
|
3814
3937
|
kiciDir,
|
|
3815
|
-
|
|
3816
|
-
registryConfig
|
|
3938
|
+
ignoreScripts,
|
|
3939
|
+
registryConfig,
|
|
3940
|
+
baseEnv
|
|
3817
3941
|
});
|
|
3818
3942
|
else await runNpmInstall({
|
|
3819
3943
|
kiciDir,
|
|
3820
|
-
|
|
3821
|
-
registryConfig
|
|
3944
|
+
ignoreScripts,
|
|
3945
|
+
registryConfig,
|
|
3946
|
+
baseEnv
|
|
3822
3947
|
});
|
|
3823
3948
|
} catch (e) {
|
|
3824
3949
|
const tokens = registryConfig.tokensForRedaction;
|
|
@@ -3828,8 +3953,8 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
3828
3953
|
} finally {
|
|
3829
3954
|
await registryConfig.cleanup();
|
|
3830
3955
|
}
|
|
3831
|
-
if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
|
|
3832
|
-
if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
|
|
3956
|
+
if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot, baseEnv);
|
|
3957
|
+
if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv);
|
|
3833
3958
|
const durationMs = Date.now() - startTime;
|
|
3834
3959
|
process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
|
|
3835
3960
|
logger$2.info("Deps installed inline", {
|
|
@@ -3837,20 +3962,26 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
3837
3962
|
durationMs
|
|
3838
3963
|
});
|
|
3839
3964
|
}
|
|
3840
|
-
/**
|
|
3841
|
-
|
|
3842
|
-
|
|
3965
|
+
/**
|
|
3966
|
+
* Build the Node binary directory onto PATH so spawned tools find `node`.
|
|
3967
|
+
*
|
|
3968
|
+
* `baseEnv` is the caller's declared environment for the subprocess. It
|
|
3969
|
+
* defaults to `process.env` because inside the runner child that IS the
|
|
3970
|
+
* sanitized job environment; an agent-process caller passes a sanitized base.
|
|
3971
|
+
*/
|
|
3972
|
+
function envWithNodeOnPath(extraEnv, nodeDir, baseEnv = process.env) {
|
|
3973
|
+
const { NODE_ENV: _NODE_ENV, ...restEnv } = baseEnv;
|
|
3843
3974
|
return {
|
|
3844
3975
|
...restEnv,
|
|
3845
3976
|
...extraEnv,
|
|
3846
|
-
PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${
|
|
3977
|
+
PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${restEnv.PATH ?? ""}`
|
|
3847
3978
|
};
|
|
3848
3979
|
}
|
|
3849
3980
|
/** Run `npm install` in `.kici/` with an isolated cache directory. */
|
|
3850
3981
|
async function runNpmInstall(args) {
|
|
3851
3982
|
const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
|
|
3852
3983
|
const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
|
|
3853
|
-
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3984
|
+
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
|
|
3854
3985
|
const buildArgs = (...prefix) => {
|
|
3855
3986
|
const a = [
|
|
3856
3987
|
...prefix,
|
|
@@ -3860,7 +3991,7 @@ async function runNpmInstall(args) {
|
|
|
3860
3991
|
"--no-audit",
|
|
3861
3992
|
"--no-fund"
|
|
3862
3993
|
];
|
|
3863
|
-
if (args.
|
|
3994
|
+
if (args.ignoreScripts) a.push("--ignore-scripts");
|
|
3864
3995
|
return a;
|
|
3865
3996
|
};
|
|
3866
3997
|
try {
|
|
@@ -3889,7 +4020,7 @@ async function runPnpmInstall(args) {
|
|
|
3889
4020
|
await assertPnpmAvailable();
|
|
3890
4021
|
const { nodeDir } = resolveNpm();
|
|
3891
4022
|
const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
|
|
3892
|
-
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
4023
|
+
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
|
|
3893
4024
|
const argv = [
|
|
3894
4025
|
"install",
|
|
3895
4026
|
`--config.store-dir=${storeDir}`,
|
|
@@ -3898,7 +4029,7 @@ async function runPnpmInstall(args) {
|
|
|
3898
4029
|
"--config.side-effects-cache=false",
|
|
3899
4030
|
PNPM_IGNORE_BUILD_GATE_ARG
|
|
3900
4031
|
];
|
|
3901
|
-
if (args.
|
|
4032
|
+
if (args.ignoreScripts) argv.push("--ignore-scripts");
|
|
3902
4033
|
try {
|
|
3903
4034
|
process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
|
|
3904
4035
|
await execFileAsync("pnpm", argv, {
|
|
@@ -3912,7 +4043,7 @@ async function runPnpmInstall(args) {
|
|
|
3912
4043
|
}
|
|
3913
4044
|
}
|
|
3914
4045
|
/** Pure: argv for `yarn install` with an isolated cache folder. */
|
|
3915
|
-
function buildYarnInstallArgs(cacheDir,
|
|
4046
|
+
function buildYarnInstallArgs(cacheDir, ignoreScripts) {
|
|
3916
4047
|
const a = [
|
|
3917
4048
|
"install",
|
|
3918
4049
|
"--cache-folder",
|
|
@@ -3920,7 +4051,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
|
|
|
3920
4051
|
"--non-interactive",
|
|
3921
4052
|
"--no-progress"
|
|
3922
4053
|
];
|
|
3923
|
-
if (
|
|
4054
|
+
if (ignoreScripts) a.push("--ignore-scripts");
|
|
3924
4055
|
return a;
|
|
3925
4056
|
}
|
|
3926
4057
|
/**
|
|
@@ -3935,8 +4066,8 @@ async function runYarnInstall(args) {
|
|
|
3935
4066
|
await assertYarnAvailable();
|
|
3936
4067
|
const { nodeDir } = resolveNpm();
|
|
3937
4068
|
const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
|
|
3938
|
-
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3939
|
-
const argv = buildYarnInstallArgs(cacheDir, args.
|
|
4069
|
+
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
|
|
4070
|
+
const argv = buildYarnInstallArgs(cacheDir, args.ignoreScripts);
|
|
3940
4071
|
try {
|
|
3941
4072
|
process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
|
|
3942
4073
|
await execFileAsync("yarn", argv, {
|
|
@@ -3966,7 +4097,7 @@ async function runYarnBerryInstall(args) {
|
|
|
3966
4097
|
await assertYarnAvailable();
|
|
3967
4098
|
const { nodeDir } = resolveNpm();
|
|
3968
4099
|
const env = {
|
|
3969
|
-
...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
|
|
4100
|
+
...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv),
|
|
3970
4101
|
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
|
3971
4102
|
};
|
|
3972
4103
|
const argv = buildYarnBerryInstallArgs();
|
|
@@ -3997,11 +4128,11 @@ async function assertYarnAvailable() {
|
|
|
3997
4128
|
* Deep cross-sibling build chains may build out of strict topological order —
|
|
3998
4129
|
* real `.kici` closures are shallow.
|
|
3999
4130
|
*/
|
|
4000
|
-
async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
|
|
4131
|
+
async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv) {
|
|
4001
4132
|
const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
|
|
4002
4133
|
if (siblings.length === 0) return;
|
|
4003
4134
|
const { nodeDir } = resolveNpm();
|
|
4004
|
-
const env = envWithNodeOnPath({}, nodeDir);
|
|
4135
|
+
const env = envWithNodeOnPath({}, nodeDir, baseEnv);
|
|
4005
4136
|
for (const rel of [...siblings].reverse()) {
|
|
4006
4137
|
const sibDir = join(repoRoot, rel);
|
|
4007
4138
|
if (!await siblingHasBuildScript(sibDir)) continue;
|
|
@@ -4045,9 +4176,9 @@ async function siblingHasBuildScript(sibDir) {
|
|
|
4045
4176
|
* the subprocess stderr/stdout is folded into the thrown error so the job's
|
|
4046
4177
|
* failure message names the real cause instead of a bare "Command failed".
|
|
4047
4178
|
*/
|
|
4048
|
-
async function buildWorkspaceClosure(repoRoot) {
|
|
4179
|
+
async function buildWorkspaceClosure(repoRoot, baseEnv) {
|
|
4049
4180
|
const { nodeDir } = resolveNpm();
|
|
4050
|
-
const env = envWithNodeOnPath({}, nodeDir);
|
|
4181
|
+
const env = envWithNodeOnPath({}, nodeDir, baseEnv);
|
|
4051
4182
|
const argv = [
|
|
4052
4183
|
"--filter",
|
|
4053
4184
|
"{.kici}^...",
|
|
@@ -4141,12 +4272,17 @@ function buildGeneratorContext(input) {
|
|
|
4141
4272
|
* ESM modules by resolved URL, so importing that path yields the workflow's live
|
|
4142
4273
|
* singleton, not a fresh copy.
|
|
4143
4274
|
*
|
|
4144
|
-
*
|
|
4275
|
+
* Both specifiers resolve to the same module-global maps — `internal.ts` and the
|
|
4276
|
+
* root barrel re-export the same `outputs.js` bindings — so the fallback below
|
|
4277
|
+
* changes which entry is imported, never which singleton is mutated.
|
|
4278
|
+
*
|
|
4279
|
+
* Falls back to the agent's bundled setters when neither resolves (mirrors
|
|
4145
4280
|
* `resolveSdkSetters` in the compiler's test runner).
|
|
4146
4281
|
*/
|
|
4147
4282
|
async function resolveWorkflowSdkSetters(workflowFilePath) {
|
|
4148
|
-
|
|
4149
|
-
|
|
4283
|
+
const req = createRequire(workflowFilePath);
|
|
4284
|
+
for (const specifier of ["@kici-dev/sdk/internal", "@kici-dev/sdk"]) try {
|
|
4285
|
+
const sdkEntry = req.resolve(specifier);
|
|
4150
4286
|
const sdk = await import(pathToFileURL(sdkEntry).href);
|
|
4151
4287
|
if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
|
|
4152
4288
|
setStepOutputsMap: sdk.setStepOutputsMap,
|
|
@@ -4160,8 +4296,8 @@ async function resolveWorkflowSdkSetters(workflowFilePath) {
|
|
|
4160
4296
|
setJobOutputsMap
|
|
4161
4297
|
};
|
|
4162
4298
|
}
|
|
4163
|
-
const AGENT_SDK_VERSION = "0.
|
|
4164
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
4299
|
+
const AGENT_SDK_VERSION = "0.8.0";
|
|
4300
|
+
const AGENT_SDK_BUNDLE_HASH = "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
|
|
4165
4301
|
/**
|
|
4166
4302
|
* Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
|
|
4167
4303
|
* for subsequent dynamic `import()` calls. Idempotent at our level via the
|
|
@@ -4201,10 +4337,49 @@ function ensureLoaderHookRegistered() {
|
|
|
4201
4337
|
* with lockfiles compiled on Linux (LF).
|
|
4202
4338
|
*/
|
|
4203
4339
|
function computeContentHash(rawSource, assetDigest) {
|
|
4204
|
-
let input =
|
|
4340
|
+
let input = `${COMPILE_SCHEMA_VERSION}:${normalizeLineEndings(rawSource)}`;
|
|
4205
4341
|
if (assetDigest !== void 0 && assetDigest.length > 0) input += `\0${normalizeLineEndings(assetDigest)}`;
|
|
4206
4342
|
return sha256(input);
|
|
4207
4343
|
}
|
|
4344
|
+
/**
|
|
4345
|
+
* The compile schema version the lock file records for `sourceFile`, or null
|
|
4346
|
+
* when the tree carries no readable lock (a `file://` in-place run, a workflow
|
|
4347
|
+
* outside the `.kici/` convention, a hand-built fixture).
|
|
4348
|
+
*
|
|
4349
|
+
* The source tarball carries `.kici/kici.lock.json` — the digest excludes it,
|
|
4350
|
+
* but `source-packer.ts` packs it — so the agent can read the producing
|
|
4351
|
+
* compiler's schema version from the tree it already has, with no wire field
|
|
4352
|
+
* to plumb and no protocol change.
|
|
4353
|
+
*
|
|
4354
|
+
* A malformed or unreadable lock returns null rather than throwing: this is a
|
|
4355
|
+
* diagnostic gate in front of the real hash check, so it must never convert a
|
|
4356
|
+
* bad lock into a worse error than the hash comparison already gives.
|
|
4357
|
+
*/
|
|
4358
|
+
async function readLockCompileSchemaVersion(kiciDir, sourceFile) {
|
|
4359
|
+
let parsed;
|
|
4360
|
+
try {
|
|
4361
|
+
parsed = JSON.parse(await fsPromises.readFile(path.join(kiciDir, "kici.lock.json"), "utf-8"));
|
|
4362
|
+
} catch {
|
|
4363
|
+
return null;
|
|
4364
|
+
}
|
|
4365
|
+
const workflows = parsed?.workflows;
|
|
4366
|
+
if (!Array.isArray(workflows)) return null;
|
|
4367
|
+
const normalize = (p) => p.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
4368
|
+
const target = normalize(sourceFile);
|
|
4369
|
+
const versionOf = (w) => {
|
|
4370
|
+
const v = w?.compileSchemaVersion;
|
|
4371
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
|
4372
|
+
};
|
|
4373
|
+
for (const w of workflows) {
|
|
4374
|
+
const file = w?.source?.file;
|
|
4375
|
+
if (typeof file === "string" && normalize(file) === target) return versionOf(w);
|
|
4376
|
+
}
|
|
4377
|
+
for (const w of workflows) {
|
|
4378
|
+
const v = versionOf(w);
|
|
4379
|
+
if (v !== null) return v;
|
|
4380
|
+
}
|
|
4381
|
+
return null;
|
|
4382
|
+
}
|
|
4208
4383
|
async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
|
|
4209
4384
|
const parts = [];
|
|
4210
4385
|
for (const rel of resolvedPaths) {
|
|
@@ -4226,20 +4401,30 @@ async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
|
|
|
4226
4401
|
* `node_modules/` the same way any `tsx`-style runner would — so host-repo
|
|
4227
4402
|
* helpers and `@kici-dev/sdk` Just Work.
|
|
4228
4403
|
*
|
|
4229
|
-
* When `expectedContentHash` is provided, verifies the
|
|
4230
|
-
* the hash in the lock file.
|
|
4231
|
-
*
|
|
4232
|
-
*
|
|
4404
|
+
* When `expectedContentHash` is provided, verifies the extracted `.kici/` tree
|
|
4405
|
+
* matches the hash in the lock file. It re-hashes the whole tree, not the entry
|
|
4406
|
+
* file alone, so an edit to an imported helper is caught — that was the gap
|
|
4407
|
+
* that let a warm cache restore a stale tarball and run the OLD helper green.
|
|
4408
|
+
* Drift produces a descriptive error that surfaces the baked agent SDK
|
|
4409
|
+
* fingerprint (useful when debugging "is the agent running a stale build?").
|
|
4233
4410
|
*/
|
|
4234
4411
|
async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, resolvedHashFiles) {
|
|
4235
4412
|
ensureLoaderHookRegistered();
|
|
4236
4413
|
const filePath = path.join(workDir, sourceFile);
|
|
4237
4414
|
if (expectedContentHash) {
|
|
4238
|
-
const
|
|
4415
|
+
const kiciDir = findKiciDir(filePath);
|
|
4416
|
+
if (kiciDir) {
|
|
4417
|
+
const lockVersion = await readLockCompileSchemaVersion(kiciDir, sourceFile);
|
|
4418
|
+
if (lockVersion !== null && lockVersion !== COMPILE_SCHEMA_VERSION) throw new Error(`kici.lock.json was compiled by an incompatible @kici-dev/compiler: the lock declares compile schema ${lockVersion}, this agent implements ${COMPILE_SCHEMA_VERSION}. The schema version is mixed into every contentHash, so recompiling cannot reconcile them. Align the versions: upgrade the agent to one implementing schema ${lockVersion}, or pin @kici-dev/compiler to a release implementing schema ${COMPILE_SCHEMA_VERSION} and recompile (agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}).`);
|
|
4419
|
+
}
|
|
4420
|
+
const rawSource = (kiciDir ? await hashKiciSourceTree(kiciDir) : "") || await fsPromises.readFile(filePath, "utf-8");
|
|
4239
4421
|
let assetDigest;
|
|
4240
4422
|
if (resolvedHashFiles?.length) assetDigest = await buildAssetDigestFromResolvedPaths(workDir, resolvedHashFiles);
|
|
4241
4423
|
const actualHash = computeContentHash(rawSource, assetDigest);
|
|
4242
|
-
if (actualHash !== expectedContentHash)
|
|
4424
|
+
if (actualHash !== expectedContentHash) {
|
|
4425
|
+
const symlinkNote = kiciDir ? hashedSymlinkDriftNote(await collectSourceSymlinks(kiciDir)) : "";
|
|
4426
|
+
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.${symlinkNote}`);
|
|
4427
|
+
}
|
|
4243
4428
|
}
|
|
4244
4429
|
return {
|
|
4245
4430
|
module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
|
|
@@ -4402,22 +4587,30 @@ function applyGlobalWorkflowEnv(repos) {
|
|
|
4402
4587
|
* `.kici/` source tarball restoration for execution agents.
|
|
4403
4588
|
*
|
|
4404
4589
|
* Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
|
|
4405
|
-
* and
|
|
4590
|
+
* and installs it at `workDir/.kici` so the workflow entry point becomes
|
|
4406
4591
|
* importable. Mirrors the shape of `dep-restore.ts` but without the streaming
|
|
4407
4592
|
* optimization — source tarballs are tiny (kilobytes, not the hundreds of
|
|
4408
4593
|
* megabytes a `node_modules/` tarball carries).
|
|
4409
4594
|
*
|
|
4410
|
-
*
|
|
4411
|
-
*
|
|
4412
|
-
*
|
|
4413
|
-
*
|
|
4414
|
-
*
|
|
4415
|
-
* `
|
|
4416
|
-
* the
|
|
4417
|
-
*
|
|
4418
|
-
*
|
|
4419
|
-
*
|
|
4420
|
-
*
|
|
4595
|
+
* Two properties this path is responsible for, both of which it previously
|
|
4596
|
+
* lacked:
|
|
4597
|
+
*
|
|
4598
|
+
* **Verification.** `dispatch.sourceTarDigest` is the SHA-256 of the tarball's
|
|
4599
|
+
* own bytes, so the download is checked before anything is extracted — the same
|
|
4600
|
+
* contract `restoreDeps` has always had via `depsHash`. The older
|
|
4601
|
+
* `dispatch.sourceTarHash` field carries the workflow `contentHash` instead, so
|
|
4602
|
+
* it never could serve this purpose; it stays on the wire for older peers and
|
|
4603
|
+
* is deliberately not used as a verification input here. When no digest is
|
|
4604
|
+
* dispatched (an older orchestrator, or a source that did not come from the
|
|
4605
|
+
* content-addressed cache) the restore proceeds unverified rather than failing,
|
|
4606
|
+
* so a mixed-version rollout still runs.
|
|
4607
|
+
*
|
|
4608
|
+
* **Replacement, not overlay.** Extraction lands in a scratch directory and the
|
|
4609
|
+
* result REPLACES `workDir/.kici` wholesale, save for `node_modules/` — the one
|
|
4610
|
+
* directory the tarball deliberately omits, which the deps restore has already
|
|
4611
|
+
* written by the time this runs. Extracting over the existing tree left any file
|
|
4612
|
+
* the tarball no longer carries in place, so a helper the author deleted
|
|
4613
|
+
* survived every warm-cache run and kept being imported.
|
|
4421
4614
|
*/
|
|
4422
4615
|
init_dep_restore();
|
|
4423
4616
|
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
@@ -4431,7 +4624,14 @@ async function extractSourceTarball(data, targetDir) {
|
|
|
4431
4624
|
})).on("finish", resolve).on("error", reject);
|
|
4432
4625
|
});
|
|
4433
4626
|
}
|
|
4434
|
-
|
|
4627
|
+
/**
|
|
4628
|
+
* Download, verify, and install the `.kici/` source tree.
|
|
4629
|
+
*
|
|
4630
|
+
* @param workDir - Root of the cloned repository; `.kici` is replaced under it
|
|
4631
|
+
* @param sourceTarUrl - `http://`, `https://`, or `file://` URL to the tarball
|
|
4632
|
+
* @param sourceTarDigest - Expected SHA-256 of the tarball bytes, when known
|
|
4633
|
+
*/
|
|
4634
|
+
async function restoreSource(workDir, sourceTarUrl, sourceTarDigest) {
|
|
4435
4635
|
sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
|
|
4436
4636
|
logger$1.info("Restoring .kici/ source from tarball", { sourceTarUrl });
|
|
4437
4637
|
const startTime = Date.now();
|
|
@@ -4441,11 +4641,34 @@ async function restoreSource(workDir, sourceTarUrl) {
|
|
|
4441
4641
|
data = await fsPromises.readFile(localPath);
|
|
4442
4642
|
} else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
|
|
4443
4643
|
else throw new Error(`Unsupported source tarball URL scheme: ${sourceTarUrl}`);
|
|
4444
|
-
|
|
4644
|
+
if (sourceTarDigest) {
|
|
4645
|
+
const actual = createHash("sha256").update(data).digest("hex");
|
|
4646
|
+
if (actual !== sourceTarDigest) throw new Error(`Source tarball hash mismatch: expected ${sourceTarDigest}, got ${actual}. The restored source does not match what the orchestrator dispatched.`);
|
|
4647
|
+
}
|
|
4648
|
+
const kiciDir = path.join(workDir, ".kici");
|
|
4649
|
+
const scratch = path.join(workDir, `.kici.restore-${process.pid}-${Date.now()}`);
|
|
4650
|
+
try {
|
|
4651
|
+
await extractSourceTarball(data, scratch);
|
|
4652
|
+
const extracted = path.join(scratch, ".kici");
|
|
4653
|
+
const src = (await fsPromises.stat(extracted).catch(() => null))?.isDirectory() ? extracted : scratch;
|
|
4654
|
+
const installedDeps = path.join(kiciDir, "node_modules");
|
|
4655
|
+
if (await fsPromises.stat(installedDeps).catch(() => null)) await rename(installedDeps, path.join(src, "node_modules"));
|
|
4656
|
+
await rm(kiciDir, {
|
|
4657
|
+
recursive: true,
|
|
4658
|
+
force: true
|
|
4659
|
+
});
|
|
4660
|
+
await rename(src, kiciDir);
|
|
4661
|
+
} finally {
|
|
4662
|
+
await rm(scratch, {
|
|
4663
|
+
recursive: true,
|
|
4664
|
+
force: true
|
|
4665
|
+
}).catch(() => {});
|
|
4666
|
+
}
|
|
4445
4667
|
const durationMs = Date.now() - startTime;
|
|
4446
4668
|
logger$1.info(".kici/ source restored", {
|
|
4447
4669
|
sizeKB: (data.length / 1024).toFixed(2),
|
|
4448
|
-
durationMs
|
|
4670
|
+
durationMs,
|
|
4671
|
+
verified: sourceTarDigest !== void 0
|
|
4449
4672
|
});
|
|
4450
4673
|
}
|
|
4451
4674
|
//#endregion
|
|
@@ -4594,7 +4817,7 @@ async function applyOverlay(config) {
|
|
|
4594
4817
|
*/
|
|
4595
4818
|
init_download();
|
|
4596
4819
|
init_dep_restore();
|
|
4597
|
-
const AGENT_VERSION = "0.
|
|
4820
|
+
const AGENT_VERSION = "0.8.0";
|
|
4598
4821
|
process.on("uncaughtException", (err) => {
|
|
4599
4822
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
4600
4823
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -4666,7 +4889,12 @@ function installOutputCapture() {
|
|
|
4666
4889
|
line
|
|
4667
4890
|
});
|
|
4668
4891
|
}
|
|
4669
|
-
if (isForkMode)
|
|
4892
|
+
if (isForkMode) {
|
|
4893
|
+
if (!captureIsActive() || process.env.KICI_RUNNER_DEBUG_STDIO === "true") return origStdoutWrite(chunk, encodingOrCb, cb);
|
|
4894
|
+
const forkCallback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
4895
|
+
if (forkCallback) forkCallback();
|
|
4896
|
+
return true;
|
|
4897
|
+
}
|
|
4670
4898
|
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
4671
4899
|
if (callback) callback();
|
|
4672
4900
|
return true;
|
|
@@ -5256,6 +5484,46 @@ function extractRepoIdentifier(repoUrl) {
|
|
|
5256
5484
|
* mint failure the statement is frozen + reported for later minting (deferred);
|
|
5257
5485
|
* the step still completes.
|
|
5258
5486
|
*/
|
|
5487
|
+
/**
|
|
5488
|
+
* The context a deferred attestation's frozen statement is built from.
|
|
5489
|
+
*
|
|
5490
|
+
* Prefers the orchestrator's `provenanceContext` — server truth, derived from
|
|
5491
|
+
* the same run row the mint reads, so the frozen statement is field-for-field
|
|
5492
|
+
* what a live mint would have produced and the orchestrator can cross-check it.
|
|
5493
|
+
*
|
|
5494
|
+
* Falls back to the agent's local view when an older orchestrator sent none.
|
|
5495
|
+
* That fallback disagrees with the claims by construction: `request.ref` is the
|
|
5496
|
+
* job's CHECKOUT ref, which for a pull request is the HEAD branch where the
|
|
5497
|
+
* claim is the BASE branch, and `request.workflowRef` is a global workflow's
|
|
5498
|
+
* CLONE ref where the claim is `<name>@<sha>`. So a statement built from it
|
|
5499
|
+
* fails the capture cross-check and the defer is dropped — a green job with no
|
|
5500
|
+
* attestation, rather than an unchecked statement the orchestrator signs.
|
|
5501
|
+
*/
|
|
5502
|
+
function buildLocalContext(request) {
|
|
5503
|
+
const ctx = request.provenanceContext;
|
|
5504
|
+
if (ctx) return {
|
|
5505
|
+
repository: ctx.repository ?? "",
|
|
5506
|
+
ref: ctx.ref ?? "",
|
|
5507
|
+
sha: ctx.sha,
|
|
5508
|
+
workflowRef: ctx.workflowRef ?? "",
|
|
5509
|
+
runId: ctx.runId,
|
|
5510
|
+
jobId: ctx.jobId,
|
|
5511
|
+
orgId: ctx.orgId,
|
|
5512
|
+
sourceOrigin: ctx.sourceOrigin,
|
|
5513
|
+
...ctx.provider ? { provider: ctx.provider } : {},
|
|
5514
|
+
issuer: ctx.issuer,
|
|
5515
|
+
orchestratorId: ctx.orchestratorId
|
|
5516
|
+
};
|
|
5517
|
+
return {
|
|
5518
|
+
repository: extractRepoIdentifier(request.repoUrl),
|
|
5519
|
+
ref: request.ref,
|
|
5520
|
+
sha: request.sha || null,
|
|
5521
|
+
workflowRef: request.workflowRef ?? request.workflowName,
|
|
5522
|
+
runId: request.runId,
|
|
5523
|
+
jobId: request.jobId,
|
|
5524
|
+
issuer: ""
|
|
5525
|
+
};
|
|
5526
|
+
}
|
|
5259
5527
|
function buildAttestProvenanceFn(request, workDir, getIdToken) {
|
|
5260
5528
|
return async (opts) => {
|
|
5261
5529
|
const subject = provenanceSubjectIsPath(opts.subject) ? {
|
|
@@ -5271,15 +5539,7 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
|
|
|
5271
5539
|
"kici-agent": AGENT_VERSION,
|
|
5272
5540
|
"kici-orchestrator": "unknown"
|
|
5273
5541
|
},
|
|
5274
|
-
localContext:
|
|
5275
|
-
repository: extractRepoIdentifier(request.repoUrl),
|
|
5276
|
-
ref: request.ref,
|
|
5277
|
-
sha: request.sha || null,
|
|
5278
|
-
workflowRef: request.workflowRef ?? request.workflowName,
|
|
5279
|
-
runId: request.runId,
|
|
5280
|
-
jobId: request.jobId,
|
|
5281
|
-
issuer: request.provenanceIssuer ?? ""
|
|
5282
|
-
},
|
|
5542
|
+
localContext: buildLocalContext(request),
|
|
5283
5543
|
reportDeferred: async (report) => {
|
|
5284
5544
|
await relayProvenanceIpc({
|
|
5285
5545
|
op: "defer",
|
|
@@ -5584,20 +5844,6 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
|
5584
5844
|
});
|
|
5585
5845
|
}
|
|
5586
5846
|
/**
|
|
5587
|
-
* Create a LogMasker initialized with all secret values from the request.
|
|
5588
|
-
*
|
|
5589
|
-
* Collects values from both flat secrets and all namespaced context secrets,
|
|
5590
|
-
* deduplicating before registration.
|
|
5591
|
-
*/
|
|
5592
|
-
function createSecretMasker(request) {
|
|
5593
|
-
const masker = new LogMasker();
|
|
5594
|
-
const allSecrets = {};
|
|
5595
|
-
if (request.secrets) Object.assign(allSecrets, request.secrets);
|
|
5596
|
-
if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
|
|
5597
|
-
masker.registerSecrets(allSecrets);
|
|
5598
|
-
return masker;
|
|
5599
|
-
}
|
|
5600
|
-
/**
|
|
5601
5847
|
* Build a fresh zx `$` shell bound to the sandbox working directory and the
|
|
5602
5848
|
* sanitized environment (process.env was set by the parent via env-sanitizer
|
|
5603
5849
|
* before spawning this process). This is the single shell-construction code
|
|
@@ -5628,12 +5874,13 @@ function createSecretMasker(request) {
|
|
|
5628
5874
|
* step$.log would only set it on the function object and NOT propagate to the
|
|
5629
5875
|
* AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
|
|
5630
5876
|
*/
|
|
5631
|
-
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
5877
|
+
function buildSandboxShell(cwd, stepIndex, maskedSendFn, signal) {
|
|
5632
5878
|
return $({
|
|
5633
5879
|
cwd,
|
|
5634
5880
|
env: process.env,
|
|
5635
5881
|
verbose: true,
|
|
5636
5882
|
quiet: false,
|
|
5883
|
+
...signal ? { signal } : {},
|
|
5637
5884
|
log: makeStreamingZxLog((line, stream) => maskedSendFn({
|
|
5638
5885
|
type: "log.line",
|
|
5639
5886
|
stepIndex,
|
|
@@ -5718,7 +5965,7 @@ function initialJobStatus(loopStatus) {
|
|
|
5718
5965
|
* inside this process with full shell access.
|
|
5719
5966
|
*/
|
|
5720
5967
|
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal, jobTempScope) {
|
|
5721
|
-
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
5968
|
+
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn, signal);
|
|
5722
5969
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
5723
5970
|
const rawPayload = rawPayloadFromEvent(request.event);
|
|
5724
5971
|
const kici = buildKiciApi(async (method, params) => {
|
|
@@ -5973,7 +6220,8 @@ async function installDependenciesIfNeeded(workflowDir, request) {
|
|
|
5973
6220
|
await installDeps(kiciDir, {
|
|
5974
6221
|
npmRegistries: request.npmRegistries,
|
|
5975
6222
|
installEnvSecrets: request.installEnvSecrets,
|
|
5976
|
-
jobIdShort: request.jobIdShort
|
|
6223
|
+
jobIdShort: request.jobIdShort,
|
|
6224
|
+
...request.allowInstallScripts ? { allowInstallScripts: true } : {}
|
|
5977
6225
|
});
|
|
5978
6226
|
trace("fallback install complete");
|
|
5979
6227
|
}
|
|
@@ -5998,7 +6246,8 @@ async function installDependenciesIfNeeded(workflowDir, request) {
|
|
|
5998
6246
|
await installDeps(kiciDir, {
|
|
5999
6247
|
npmRegistries: request.npmRegistries,
|
|
6000
6248
|
installEnvSecrets: request.installEnvSecrets,
|
|
6001
|
-
jobIdShort: request.jobIdShort
|
|
6249
|
+
jobIdShort: request.jobIdShort,
|
|
6250
|
+
...request.allowInstallScripts ? { allowInstallScripts: true } : {}
|
|
6002
6251
|
});
|
|
6003
6252
|
trace("installDeps() returned successfully");
|
|
6004
6253
|
} catch (depErr) {
|
|
@@ -6028,7 +6277,7 @@ async function restoreSourceTarballIfRequested(workflowRoot, request) {
|
|
|
6028
6277
|
stepIndex: -1,
|
|
6029
6278
|
line: "[workflow-runner] Restoring .kici/ source from cached tarball"
|
|
6030
6279
|
});
|
|
6031
|
-
await restoreSource(workflowRoot, request.sourceTarUrl);
|
|
6280
|
+
await restoreSource(workflowRoot, request.sourceTarUrl, request.sourceTarDigest);
|
|
6032
6281
|
trace("source tarball restored");
|
|
6033
6282
|
}
|
|
6034
6283
|
/**
|
|
@@ -6529,7 +6778,7 @@ function buildJobRuleCompletion(ruleResult, normalizedSteps) {
|
|
|
6529
6778
|
* success-skip). Returns false when the caller should continue to step
|
|
6530
6779
|
* execution.
|
|
6531
6780
|
*/
|
|
6532
|
-
async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
|
|
6781
|
+
async function maybeSkipJobOnRules(job, request, normalizedSteps, send, repos) {
|
|
6533
6782
|
if (!job?.rules || job.rules.length === 0) return false;
|
|
6534
6783
|
const ev = request.event ?? {};
|
|
6535
6784
|
const ruleCtx = createRuleContext({
|
|
@@ -6548,7 +6797,7 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
|
|
|
6548
6797
|
if (!completion) return false;
|
|
6549
6798
|
flushOutputCapture();
|
|
6550
6799
|
capturePrepareActive = false;
|
|
6551
|
-
|
|
6800
|
+
send(completion);
|
|
6552
6801
|
process.exit(0);
|
|
6553
6802
|
}
|
|
6554
6803
|
/**
|
|
@@ -6774,11 +7023,7 @@ async function main() {
|
|
|
6774
7023
|
const sourceDir = isGlobal ? join(workDir, "source") : workDir;
|
|
6775
7024
|
const masker = createSecretMasker(request);
|
|
6776
7025
|
const maskedSend = (msg) => {
|
|
6777
|
-
|
|
6778
|
-
...msg,
|
|
6779
|
-
line: masker.mask(msg.line)
|
|
6780
|
-
});
|
|
6781
|
-
else sendMessage(msg);
|
|
7026
|
+
sendMessage(maskMessageText(msg, masker));
|
|
6782
7027
|
};
|
|
6783
7028
|
const jobDeadline = armJobDeadline(request.jobTimeoutMs, (reason, timeoutMs) => {
|
|
6784
7029
|
jobTimedOut = true;
|
|
@@ -6813,7 +7058,7 @@ async function main() {
|
|
|
6813
7058
|
const jobHasRules = (job?.rules?.length ?? 0) > 0;
|
|
6814
7059
|
const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
|
|
6815
7060
|
await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
|
|
6816
|
-
await maybeSkipJobOnRules(job, request, normalizedSteps, globalRepoInfo);
|
|
7061
|
+
await maybeSkipJobOnRules(job, request, normalizedSteps, maskedSend, globalRepoInfo);
|
|
6817
7062
|
if (aborted) abortAndExit("aborted after rules");
|
|
6818
7063
|
}
|
|
6819
7064
|
const jobHooks = collectJobHooks(job);
|
|
@@ -6922,7 +7167,8 @@ async function main() {
|
|
|
6922
7167
|
jobTimedOut,
|
|
6923
7168
|
jobTimeoutMs: request.jobTimeoutMs,
|
|
6924
7169
|
cancelFailureReason,
|
|
6925
|
-
driftDroppedJobs
|
|
7170
|
+
driftDroppedJobs,
|
|
7171
|
+
send: maskedSend
|
|
6926
7172
|
});
|
|
6927
7173
|
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
6928
7174
|
}
|
|
@@ -6935,7 +7181,7 @@ async function main() {
|
|
|
6935
7181
|
function emitJobComplete(args) {
|
|
6936
7182
|
const aggregatedOutputs = {};
|
|
6937
7183
|
for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
6938
|
-
|
|
7184
|
+
args.send({
|
|
6939
7185
|
type: "job.complete",
|
|
6940
7186
|
status: args.finalStatus,
|
|
6941
7187
|
stepResults: args.loopResult.stepResults,
|