@kici-dev/agent 0.1.16 → 0.1.18
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/execution/dep-installer.d.ts +12 -3
- package/dist/execution/dep-packer.d.ts +9 -1
- package/dist/execution/env-init/presets/directives.d.ts +20 -0
- package/dist/execution/env-init/presets/expand.d.ts +16 -0
- package/dist/execution/env-init/presets/mise/cache-key.d.ts +7 -0
- package/dist/execution/env-init/presets/mise/expander.d.ts +16 -0
- package/dist/execution/env-init/presets/mise/templates.d.ts +16 -0
- package/dist/execution/env-init/presets/mise/windows-install.d.ts +18 -0
- package/dist/execution/env-init/presets/registry.d.ts +31 -0
- package/dist/execution/init-runner.d.ts +7 -0
- package/dist/execution/job-runner.d.ts +9 -1
- package/dist/execution/sandbox/env-delta.d.ts +2 -0
- package/dist/execution/sandbox/index.d.ts +1 -1
- package/dist/execution/sandbox/ipc-protocol.d.ts +81 -3
- package/dist/execution/sandbox/types.d.ts +9 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +12 -7
- package/dist/execution/validate-kici-deps.d.ts +10 -2
- package/dist/execution/workflow-loader.d.ts +5 -1
- package/dist/execution/workspace-siblings.d.ts +33 -0
- package/dist/execution/yarnrc-berry-config.d.ts +23 -0
- package/dist/index.js +423 -17
- package/dist/provenance/attest.d.ts +30 -0
- package/dist/provenance/sign.d.ts +21 -0
- package/dist/provenance/statement-builder.d.ts +38 -0
- package/dist/server.js +703 -160
- package/dist/version.d.ts +2 -0
- package/dist/workflow-runner.js +955 -59
- package/dist/ws/orchestrator-client.d.ts +16 -1
- package/package.json +14 -12
- package/sbom.spdx.json +2526 -5994
package/dist/workflow-runner.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { register } from "node:module";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import crypto$1, { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import fsPromises, { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import fsPromises, { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import os, { homedir, tmpdir } from "node:os";
|
|
7
7
|
import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { $ } from "zx";
|
|
9
9
|
import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
|
|
10
10
|
import { CacheOutcome, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
|
|
11
|
-
import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeCacheSpecs, normalizeRequireApproval, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
11
|
+
import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeCacheSpecs, normalizeRequireApproval, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
12
|
+
import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
|
|
13
|
+
import { sha256File as sha256File$1 } from "@kici-dev/core";
|
|
14
|
+
import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
|
|
15
|
+
import { IN_TOTO_PAYLOAD_TYPE, KICI_PROVENANCE_AUDIENCE, KICI_PROVENANCE_BUNDLE_MEDIA_TYPE } from "@kici-dev/engine/provenance/bundle";
|
|
16
|
+
import { IN_TOTO_STATEMENT_TYPE, KICI_WORKFLOW_BUILD_TYPE, SLSA_PROVENANCE_PREDICATE_TYPE } from "@kici-dev/engine/provenance/schema";
|
|
17
|
+
import { buildDsseEnvelope, dssePae } from "@kici-dev/engine/provenance/dsse";
|
|
18
|
+
import https from "node:https";
|
|
19
|
+
import http from "node:http";
|
|
12
20
|
import { Readable, Transform } from "node:stream";
|
|
13
21
|
import { pipeline } from "node:stream/promises";
|
|
14
22
|
import { createGunzip } from "node:zlib";
|
|
15
|
-
import { c, x } from "tar";
|
|
16
|
-
import https from "node:https";
|
|
17
|
-
import http from "node:http";
|
|
18
23
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
|
+
import { c, x } from "tar";
|
|
19
25
|
import { execFile } from "node:child_process";
|
|
20
26
|
import { promisify } from "node:util";
|
|
21
|
-
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
|
|
27
|
+
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
|
|
28
|
+
import { parse, stringify } from "yaml";
|
|
22
29
|
var __defProp = Object.defineProperty;
|
|
23
30
|
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
24
31
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -32,6 +39,122 @@ var __exportAll = (all, no_symbols) => {
|
|
|
32
39
|
};
|
|
33
40
|
import.meta.url;
|
|
34
41
|
//#endregion
|
|
42
|
+
//#region src/provenance/statement-builder.ts
|
|
43
|
+
/**
|
|
44
|
+
* Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
|
|
45
|
+
* token claims plus the caller-supplied subject. The build context comes
|
|
46
|
+
* entirely from the JWT claims (Platform-minted, unforgeable), so the
|
|
47
|
+
* statement's identity equals the token's identity by construction.
|
|
48
|
+
*/
|
|
49
|
+
/** Build a KiCI SLSA v1.0 provenance statement (validates against the P1.1 schema). */
|
|
50
|
+
function buildProvenanceStatement(input) {
|
|
51
|
+
const c = input.tokenClaims;
|
|
52
|
+
return {
|
|
53
|
+
_type: IN_TOTO_STATEMENT_TYPE,
|
|
54
|
+
subject: [{
|
|
55
|
+
name: input.subject.name,
|
|
56
|
+
digest: input.subject.digest
|
|
57
|
+
}],
|
|
58
|
+
predicateType: SLSA_PROVENANCE_PREDICATE_TYPE,
|
|
59
|
+
predicate: {
|
|
60
|
+
buildDefinition: {
|
|
61
|
+
buildType: KICI_WORKFLOW_BUILD_TYPE,
|
|
62
|
+
externalParameters: { workflow: {
|
|
63
|
+
repository: c.repository ?? "",
|
|
64
|
+
ref: c.ref ?? "",
|
|
65
|
+
path: c.workflow_ref ?? ""
|
|
66
|
+
} },
|
|
67
|
+
internalParameters: {
|
|
68
|
+
...c.sha ? { commit: c.sha } : {},
|
|
69
|
+
runId: c.kici_run_id,
|
|
70
|
+
jobId: c.kici_job_id
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
runDetails: {
|
|
74
|
+
builder: {
|
|
75
|
+
id: `${c.iss}/orchestrator/${c.orchestrator_id ?? "unknown"}`,
|
|
76
|
+
version: input.builderVersions
|
|
77
|
+
},
|
|
78
|
+
metadata: {
|
|
79
|
+
invocationId: c.kici_run_id,
|
|
80
|
+
startedOn: input.startedOn,
|
|
81
|
+
finishedOn: input.finishedOn
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/provenance/sign.ts
|
|
89
|
+
/**
|
|
90
|
+
* Ephemeral-key DSSE signer for KiCI provenance (Mode A).
|
|
91
|
+
*
|
|
92
|
+
* Generates a fresh in-process ES256 keypair (never persisted), DSSE-signs the
|
|
93
|
+
* PAE of the statement bytes with the private half, and returns the envelope
|
|
94
|
+
* plus the public JWK. The public key travels in the bundle so the verifier can
|
|
95
|
+
* check the signature; the key needs no separate trust root because the bundle's
|
|
96
|
+
* identity JWT (verified against the Platform JWKS) anchors the whole package.
|
|
97
|
+
*/
|
|
98
|
+
/** DSSE-sign `statementBytes` with a fresh in-process ephemeral ES256 key. */
|
|
99
|
+
async function signStatementDsse(payloadType, statementBytes) {
|
|
100
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true });
|
|
101
|
+
const publicJwk = await exportJWK(publicKey);
|
|
102
|
+
publicJwk.alg = "ES256";
|
|
103
|
+
publicJwk.use = "sig";
|
|
104
|
+
const kid = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
105
|
+
publicJwk.kid = kid;
|
|
106
|
+
const pae = dssePae(payloadType, statementBytes);
|
|
107
|
+
return {
|
|
108
|
+
envelope: buildDsseEnvelope(payloadType, statementBytes, [{
|
|
109
|
+
keyid: kid,
|
|
110
|
+
sig: new Uint8Array(await crypto.subtle.sign({
|
|
111
|
+
name: "ECDSA",
|
|
112
|
+
hash: "SHA-256"
|
|
113
|
+
}, privateKey, pae))
|
|
114
|
+
}]),
|
|
115
|
+
publicJwk
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/provenance/attest.ts
|
|
120
|
+
/**
|
|
121
|
+
* Provenance attestation orchestration (Mode A): request the identity token,
|
|
122
|
+
* build the in-toto statement from its claims, DSSE-sign it with an ephemeral
|
|
123
|
+
* key, assemble the KiCI bundle, and persist it.
|
|
124
|
+
*/
|
|
125
|
+
async function attestProvenance(deps, input) {
|
|
126
|
+
const audience = input.audience ?? KICI_PROVENANCE_AUDIENCE;
|
|
127
|
+
const { token } = await deps.getIdToken({ audience });
|
|
128
|
+
const claims = decodeJwt(token);
|
|
129
|
+
const now = (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
|
|
130
|
+
const statement = buildProvenanceStatement({
|
|
131
|
+
tokenClaims: claims,
|
|
132
|
+
subject: input.subject,
|
|
133
|
+
builderVersions: deps.builderVersions,
|
|
134
|
+
startedOn: now,
|
|
135
|
+
finishedOn: now
|
|
136
|
+
});
|
|
137
|
+
const { envelope, publicJwk } = await signStatementDsse(IN_TOTO_PAYLOAD_TYPE, new TextEncoder().encode(JSON.stringify(statement)));
|
|
138
|
+
const bundle = {
|
|
139
|
+
mediaType: KICI_PROVENANCE_BUNDLE_MEDIA_TYPE,
|
|
140
|
+
dsseEnvelope: envelope,
|
|
141
|
+
verificationMaterial: {
|
|
142
|
+
publicKey: publicJwk,
|
|
143
|
+
identityToken: token
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const subjectDigest = subjectDigestString(input.subject);
|
|
147
|
+
return {
|
|
148
|
+
storageKey: await deps.persist(bundle, subjectDigest),
|
|
149
|
+
bundle,
|
|
150
|
+
subjectDigest
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Pick the primary digest (`sha256` preferred) as the storage-key discriminator. */
|
|
154
|
+
function subjectDigestString(subject) {
|
|
155
|
+
return subject.digest.sha256 ?? Object.values(subject.digest)[0];
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
35
158
|
//#region src/execution/dep-restore.ts
|
|
36
159
|
/**
|
|
37
160
|
* Dependency restoration from cached tarballs.
|
|
@@ -370,6 +493,7 @@ var init_download = __esmMin((() => {
|
|
|
370
493
|
}));
|
|
371
494
|
//#endregion
|
|
372
495
|
//#region src/execution/cache/cache-engine.ts
|
|
496
|
+
init_download();
|
|
373
497
|
/**
|
|
374
498
|
* User-facing cache engine (sandbox-side).
|
|
375
499
|
*
|
|
@@ -769,7 +893,8 @@ function applyEnvDelta(delta, options) {
|
|
|
769
893
|
}
|
|
770
894
|
const appliedPaths = [];
|
|
771
895
|
if (delta.pathPrepends.length > 0) {
|
|
772
|
-
|
|
896
|
+
const sep = options.pathSeparator ?? (process.platform === "win32" ? ";" : ":");
|
|
897
|
+
for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}${sep}${target.PATH}` : dir;
|
|
773
898
|
appliedPaths.push(...delta.pathPrepends);
|
|
774
899
|
}
|
|
775
900
|
return {
|
|
@@ -1514,6 +1639,256 @@ async function runOneInit(spec, index, stepIndex, stepType, opts) {
|
|
|
1514
1639
|
}
|
|
1515
1640
|
}
|
|
1516
1641
|
//#endregion
|
|
1642
|
+
//#region src/execution/env-init/presets/directives.ts
|
|
1643
|
+
function isPresetString(item) {
|
|
1644
|
+
return item === "mise";
|
|
1645
|
+
}
|
|
1646
|
+
function isMiseObject(item) {
|
|
1647
|
+
return typeof item === "object" && item !== null && "mise" in item;
|
|
1648
|
+
}
|
|
1649
|
+
function normalizeOne(item) {
|
|
1650
|
+
if (isPresetString(item)) return {
|
|
1651
|
+
kind: "preset",
|
|
1652
|
+
name: "mise",
|
|
1653
|
+
config: {}
|
|
1654
|
+
};
|
|
1655
|
+
if (isMiseObject(item)) return {
|
|
1656
|
+
kind: "preset",
|
|
1657
|
+
name: "mise",
|
|
1658
|
+
config: item.mise
|
|
1659
|
+
};
|
|
1660
|
+
return {
|
|
1661
|
+
kind: "generic",
|
|
1662
|
+
config: item
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Normalize `Job.init` to an ordered list of directives, without touching the
|
|
1667
|
+
* filesystem. `false`/`undefined` -> []; `'auto'` -> one auto directive;
|
|
1668
|
+
* presets/generic configs -> their directive; arrays map element-wise.
|
|
1669
|
+
* `'auto'` is a scalar only — finding it inside an array throws.
|
|
1670
|
+
*/
|
|
1671
|
+
function normalizeInitItems(job) {
|
|
1672
|
+
const init = job?.init;
|
|
1673
|
+
if (init === void 0 || init === false) return [];
|
|
1674
|
+
if (init === "auto") return [{ kind: "auto" }];
|
|
1675
|
+
if (Array.isArray(init)) return init.map((item) => {
|
|
1676
|
+
if (item === "auto") throw new Error("init: 'auto' cannot be combined in an array — use it as the sole value");
|
|
1677
|
+
return normalizeOne(item);
|
|
1678
|
+
});
|
|
1679
|
+
return [normalizeOne(init)];
|
|
1680
|
+
}
|
|
1681
|
+
//#endregion
|
|
1682
|
+
//#region src/execution/env-init/presets/mise/cache-key.ts
|
|
1683
|
+
/** mise config files, in the fixed order they feed the content hash. */
|
|
1684
|
+
const MISE_CONFIG_FILES = [
|
|
1685
|
+
"mise.toml",
|
|
1686
|
+
".mise.toml",
|
|
1687
|
+
".tool-versions"
|
|
1688
|
+
];
|
|
1689
|
+
/**
|
|
1690
|
+
* Derive the default mise cache key from the committed mise config under
|
|
1691
|
+
* `cloneRoot`. Concatenates whichever of {@link MISE_CONFIG_FILES} exist (in
|
|
1692
|
+
* fixed order) and hashes them. Returns `mise-noconfig` when none exist.
|
|
1693
|
+
*/
|
|
1694
|
+
async function miseCacheKey(cloneRoot) {
|
|
1695
|
+
const hash = createHash("sha256");
|
|
1696
|
+
let found = false;
|
|
1697
|
+
for (const name of MISE_CONFIG_FILES) try {
|
|
1698
|
+
const buf = await readFile(join(cloneRoot, name));
|
|
1699
|
+
hash.update(name);
|
|
1700
|
+
hash.update(buf);
|
|
1701
|
+
found = true;
|
|
1702
|
+
} catch {}
|
|
1703
|
+
if (!found) return "mise-noconfig";
|
|
1704
|
+
return `mise-${hash.digest("hex").slice(0, 16)}`;
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/execution/env-init/presets/mise/templates.ts
|
|
1708
|
+
const BASH_RUN = `set -euo pipefail
|
|
1709
|
+
command -v mise >/dev/null || curl -fsSL https://mise.run | sh
|
|
1710
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
1711
|
+
# Trust the committed config at the clone root (CWD): mise refuses to load an
|
|
1712
|
+
# untrusted config, and the author committing it to their repo is the trust signal.
|
|
1713
|
+
mise trust
|
|
1714
|
+
mise install
|
|
1715
|
+
mise env -s bash | sed -n 's/^export //p' | sed '/^PATH=/d' \\
|
|
1716
|
+
| sed -E 's/^([A-Za-z_][A-Za-z0-9_]*)="(.*)"$/\\1=\\2/' >> "$KICI_ENV"
|
|
1717
|
+
echo "$HOME/.local/share/mise/shims" >> "$KICI_PATH"`;
|
|
1718
|
+
const PWSH_RUN = `$ErrorActionPreference = 'Stop'
|
|
1719
|
+
# mise writes informational output (\`mise trusted …\`, install progress) to
|
|
1720
|
+
# stderr even on success. Under \`$ErrorActionPreference = 'Stop'\` PowerShell
|
|
1721
|
+
# turns any native-command stderr line into a terminating error, so a
|
|
1722
|
+
# successful \`mise trust\` would abort the step. Run each mise invocation with
|
|
1723
|
+
# the preference relaxed and gate on the real exit code via \`$LASTEXITCODE\`.
|
|
1724
|
+
function Invoke-Mise {
|
|
1725
|
+
param([Parameter(ValueFromRemainingArguments = $true)][string[]] $MiseArgs)
|
|
1726
|
+
$prev = $ErrorActionPreference
|
|
1727
|
+
$ErrorActionPreference = 'Continue'
|
|
1728
|
+
try {
|
|
1729
|
+
$output = & mise @MiseArgs 2>&1
|
|
1730
|
+
$code = $LASTEXITCODE
|
|
1731
|
+
} finally {
|
|
1732
|
+
$ErrorActionPreference = $prev
|
|
1733
|
+
}
|
|
1734
|
+
if ($code -ne 0) {
|
|
1735
|
+
throw "mise $($MiseArgs -join ' ') failed (exit $code): $($output -join ' | ')"
|
|
1736
|
+
}
|
|
1737
|
+
return $output
|
|
1738
|
+
}
|
|
1739
|
+
if (-not (Get-Command mise -ErrorAction SilentlyContinue)) {
|
|
1740
|
+
# The standalone Windows zip extracts to mise/bin/mise.exe, so prepend the
|
|
1741
|
+
# nested bin dir (not the extraction root) to PATH.
|
|
1742
|
+
$dest = Join-Path $env:USERPROFILE '.local\\mise'
|
|
1743
|
+
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
|
1744
|
+
$zip = Join-Path $env:TEMP 'mise.zip'
|
|
1745
|
+
Invoke-WebRequest -Uri '<ASSET_URL>' -OutFile $zip
|
|
1746
|
+
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
|
1747
|
+
$env:PATH = "$dest\\mise\\bin;$env:PATH"
|
|
1748
|
+
}
|
|
1749
|
+
# Trust the committed config at the clone root (CWD) — mise refuses to load an
|
|
1750
|
+
# untrusted config; the author committing it to their repo is the trust signal.
|
|
1751
|
+
Invoke-Mise trust | Out-Null
|
|
1752
|
+
Invoke-Mise install | Out-Null
|
|
1753
|
+
Invoke-Mise env -s pwsh | ForEach-Object {
|
|
1754
|
+
if ($_ -match '^\\$env:([^=]+) = ''(.*)''$' -and $Matches[1] -ne 'PATH') { "$($Matches[1])=$($Matches[2])" }
|
|
1755
|
+
} | Add-Content -Path $env:KICI_ENV
|
|
1756
|
+
# Add the real tool install dirs (not the shims dir): the standalone mise lives
|
|
1757
|
+
# in a temp dir that is gone by step time, so the shim wrappers (which re-invoke
|
|
1758
|
+
# mise) cannot resolve it. bin-paths points straight at the installed binaries.
|
|
1759
|
+
Invoke-Mise bin-paths | Add-Content -Path $env:KICI_PATH`;
|
|
1760
|
+
/**
|
|
1761
|
+
* Pick the mise template for a host platform (Node `process.platform` value).
|
|
1762
|
+
* The Windows `run` carries an `<ASSET_URL>` placeholder the expander replaces
|
|
1763
|
+
* with the resolved GitHub-release zip URL.
|
|
1764
|
+
*/
|
|
1765
|
+
function selectMiseTemplate(platform) {
|
|
1766
|
+
if (platform === "win32") return {
|
|
1767
|
+
run: PWSH_RUN,
|
|
1768
|
+
shell: "pwsh",
|
|
1769
|
+
cachePaths: ["~/AppData/Local/mise"]
|
|
1770
|
+
};
|
|
1771
|
+
if (platform === "linux" || platform === "darwin") return {
|
|
1772
|
+
run: BASH_RUN,
|
|
1773
|
+
shell: "bash",
|
|
1774
|
+
cachePaths: ["~/.local/share/mise"]
|
|
1775
|
+
};
|
|
1776
|
+
throw new Error(`unsupported platform for mise preset: ${platform}`);
|
|
1777
|
+
}
|
|
1778
|
+
//#endregion
|
|
1779
|
+
//#region src/execution/env-init/presets/mise/windows-install.ts
|
|
1780
|
+
/** Map a Windows `PROCESSOR_ARCHITECTURE` value to mise's asset arch slug. */
|
|
1781
|
+
function miseWindowsArch(processorArch) {
|
|
1782
|
+
return processorArch?.toUpperCase() === "ARM64" ? "arm64" : "x64";
|
|
1783
|
+
}
|
|
1784
|
+
const LATEST_RELEASE_URL = "https://api.github.com/repos/jdx/mise/releases/latest";
|
|
1785
|
+
/**
|
|
1786
|
+
* Resolve the download URL of the latest mise standalone Windows zip for `arch`.
|
|
1787
|
+
* `fetchJson` is injected (defaults to a real fetch) so the resolution is
|
|
1788
|
+
* unit-testable without network.
|
|
1789
|
+
*/
|
|
1790
|
+
async function resolveLatestMiseWindowsAsset(arch, fetchJson = defaultFetchJson) {
|
|
1791
|
+
const release = await fetchJson(LATEST_RELEASE_URL);
|
|
1792
|
+
const suffix = `-windows-${arch}.zip`;
|
|
1793
|
+
const asset = release.assets.find((a) => a.name.endsWith(suffix));
|
|
1794
|
+
if (!asset) throw new Error(`no mise windows ${arch} asset in latest release`);
|
|
1795
|
+
return asset.browser_download_url;
|
|
1796
|
+
}
|
|
1797
|
+
async function defaultFetchJson(url) {
|
|
1798
|
+
const res = await fetch(url, { headers: { "user-agent": "kici-agent" } });
|
|
1799
|
+
if (!res.ok) throw new Error(`mise release lookup failed: ${res.status}`);
|
|
1800
|
+
return await res.json();
|
|
1801
|
+
}
|
|
1802
|
+
//#endregion
|
|
1803
|
+
//#region src/execution/env-init/presets/mise/expander.ts
|
|
1804
|
+
async function buildRun(args, template) {
|
|
1805
|
+
if ((args.platform ?? process.platform) !== "win32") return template.run;
|
|
1806
|
+
const arch = miseWindowsArch(process.env.PROCESSOR_ARCHITECTURE);
|
|
1807
|
+
const url = await (args.resolveWindowsAsset ?? ((a) => resolveLatestMiseWindowsAsset(a)))(arch);
|
|
1808
|
+
return template.run.replace("<ASSET_URL>", url);
|
|
1809
|
+
}
|
|
1810
|
+
async function defaultCache(cloneRoot, paths) {
|
|
1811
|
+
return {
|
|
1812
|
+
key: await miseCacheKey(cloneRoot),
|
|
1813
|
+
paths,
|
|
1814
|
+
restoreKeys: ["mise-"]
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
//#endregion
|
|
1818
|
+
//#region src/execution/env-init/presets/registry.ts
|
|
1819
|
+
/**
|
|
1820
|
+
* The set of typed presets. Nix is added here (one row) once its provider lands.
|
|
1821
|
+
*/
|
|
1822
|
+
const PRESET_REGISTRY = { mise: { async expand(args) {
|
|
1823
|
+
const template = selectMiseTemplate(args.platform ?? process.platform);
|
|
1824
|
+
const run = await buildRun(args, template);
|
|
1825
|
+
const cache = args.config.cache === false ? void 0 : args.config.cache ?? await defaultCache(args.cloneRoot, template.cachePaths);
|
|
1826
|
+
const cfg = {
|
|
1827
|
+
run,
|
|
1828
|
+
shell: args.config.shell ?? template.shell,
|
|
1829
|
+
timeout: args.config.timeout ?? 6e5
|
|
1830
|
+
};
|
|
1831
|
+
if (cache) cfg.cache = cache;
|
|
1832
|
+
if (args.config.env) cfg.env = args.config.env;
|
|
1833
|
+
return cfg;
|
|
1834
|
+
} } };
|
|
1835
|
+
/**
|
|
1836
|
+
* Ordered auto-detect table: `init: 'auto'` tries each row against the clone
|
|
1837
|
+
* root and accumulates matches in this order. (nix row added with its provider.)
|
|
1838
|
+
*/
|
|
1839
|
+
const AUTO_DETECT_TABLE = [{
|
|
1840
|
+
markers: [
|
|
1841
|
+
"mise.toml",
|
|
1842
|
+
".mise.toml",
|
|
1843
|
+
".tool-versions"
|
|
1844
|
+
],
|
|
1845
|
+
preset: "mise"
|
|
1846
|
+
}];
|
|
1847
|
+
//#endregion
|
|
1848
|
+
//#region src/execution/env-init/presets/expand.ts
|
|
1849
|
+
async function fileExists$2(p) {
|
|
1850
|
+
try {
|
|
1851
|
+
await access(p);
|
|
1852
|
+
return true;
|
|
1853
|
+
} catch {
|
|
1854
|
+
return false;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
/** Scan the clone root for marker files and return matched presets in table order. */
|
|
1858
|
+
async function autoDetect(cloneRoot) {
|
|
1859
|
+
const matched = [];
|
|
1860
|
+
for (const row of AUTO_DETECT_TABLE) for (const marker of row.markers) if (await fileExists$2(join(cloneRoot, marker))) {
|
|
1861
|
+
matched.push(row.preset);
|
|
1862
|
+
break;
|
|
1863
|
+
}
|
|
1864
|
+
return matched;
|
|
1865
|
+
}
|
|
1866
|
+
async function expandPreset(name, config, opts) {
|
|
1867
|
+
return PRESET_REGISTRY[name].expand({
|
|
1868
|
+
cloneRoot: opts.cloneRoot,
|
|
1869
|
+
config,
|
|
1870
|
+
...opts.platform ? { platform: opts.platform } : {}
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Expand normalized directives into concrete generic init configs, reading the
|
|
1875
|
+
* clone root for preset cache keys and `'auto'` marker detection.
|
|
1876
|
+
*/
|
|
1877
|
+
async function expandInitDirectives(directives, opts) {
|
|
1878
|
+
const out = [];
|
|
1879
|
+
for (const d of directives) if (d.kind === "generic") out.push(d.config);
|
|
1880
|
+
else if (d.kind === "preset") out.push(await expandPreset(d.name, d.config, opts));
|
|
1881
|
+
else {
|
|
1882
|
+
const presets = await autoDetect(opts.cloneRoot);
|
|
1883
|
+
if (presets.length === 0) {
|
|
1884
|
+
opts.log?.("[kici] init: auto — no toolchain detected (no mise.toml / .tool-versions)");
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
for (const name of presets) out.push(await expandPreset(name, {}, opts));
|
|
1888
|
+
}
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1891
|
+
//#endregion
|
|
1517
1892
|
//#region src/execution/sandbox/job-deadline.ts
|
|
1518
1893
|
/**
|
|
1519
1894
|
* Arm a job-level wall-clock deadline. When `timeoutMs` is set and elapses
|
|
@@ -1801,7 +2176,7 @@ function noopResult() {
|
|
|
1801
2176
|
};
|
|
1802
2177
|
}
|
|
1803
2178
|
/** Build the synthesized env-var name for registry index `i`. */
|
|
1804
|
-
function tokenEnvName(jobIdShort, index) {
|
|
2179
|
+
function tokenEnvName$1(jobIdShort, index) {
|
|
1805
2180
|
return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
|
|
1806
2181
|
}
|
|
1807
2182
|
/** Render the agent-managed block of `.npmrc` lines. */
|
|
@@ -1810,7 +2185,7 @@ function renderAgentLines(registries, jobIdShort) {
|
|
|
1810
2185
|
const lines = [];
|
|
1811
2186
|
for (let i = 0; i < registries.length; i++) {
|
|
1812
2187
|
const reg = registries[i];
|
|
1813
|
-
const envVar = tokenEnvName(jobIdShort, i);
|
|
2188
|
+
const envVar = tokenEnvName$1(jobIdShort, i);
|
|
1814
2189
|
const authKey = reg.url.replace(/^https?:/, "");
|
|
1815
2190
|
if (reg.scope) lines.push(`${reg.scope}:registry=${reg.url}`);
|
|
1816
2191
|
else lines.push(`registry=${reg.url}`);
|
|
@@ -1842,7 +2217,7 @@ async function applyNpmRegistryConfig(args) {
|
|
|
1842
2217
|
const tokenEnv = {};
|
|
1843
2218
|
const tokensForRedaction = [];
|
|
1844
2219
|
for (let i = 0; i < registries.length; i++) {
|
|
1845
|
-
tokenEnv[tokenEnvName(args.jobIdShort, i)] = registries[i].token;
|
|
2220
|
+
tokenEnv[tokenEnvName$1(args.jobIdShort, i)] = registries[i].token;
|
|
1846
2221
|
tokensForRedaction.push(registries[i].token);
|
|
1847
2222
|
}
|
|
1848
2223
|
for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
|
|
@@ -1878,6 +2253,112 @@ function redactNpmOutput(input, tokens) {
|
|
|
1878
2253
|
}
|
|
1879
2254
|
return out;
|
|
1880
2255
|
}
|
|
2256
|
+
//#endregion
|
|
2257
|
+
//#region src/execution/yarnrc-berry-config.ts
|
|
2258
|
+
/**
|
|
2259
|
+
* Apply yarn-berry registry auth + a forced `nodeLinker: node-modules` to a
|
|
2260
|
+
* workflow's `.kici/.yarnrc.yml` for the lifetime of one `yarn install`, then
|
|
2261
|
+
* restore the file on cleanup. The berry analog of `npm-registry-config.ts`:
|
|
2262
|
+
* berry reads `.yarnrc.yml` (not `.npmrc`), so the auth block uses berry's
|
|
2263
|
+
* `npmRegistryServer` / `npmScopes` / `npmAuthToken` keys with `${VAR}`
|
|
2264
|
+
* env-var interpolation. Token bytes never reach disk — each registry token is
|
|
2265
|
+
* exposed as a job-scoped env var and the on-disk value is the `${VAR}`
|
|
2266
|
+
* reference.
|
|
2267
|
+
*
|
|
2268
|
+
* `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
|
|
2269
|
+
* (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
|
|
2270
|
+
* workflow-loader work unchanged. `enableScripts: false` (when a private
|
|
2271
|
+
* registry is configured) keeps dependency lifecycle scripts from seeing the
|
|
2272
|
+
* synthesized token env vars — the same security model as npm/pnpm/classic
|
|
2273
|
+
* `--ignore-scripts`.
|
|
2274
|
+
*
|
|
2275
|
+
* Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
|
|
2276
|
+
* shapes as the npm overlay so `dep-installer` can pick either by flavor.
|
|
2277
|
+
*/
|
|
2278
|
+
/** Build the synthesized env-var name for registry index `i`. */
|
|
2279
|
+
function tokenEnvName(jobIdShort, index) {
|
|
2280
|
+
return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
|
|
2281
|
+
}
|
|
2282
|
+
/** Read + parse an existing `.yarnrc.yml`, or `{}` when absent/empty. */
|
|
2283
|
+
async function readOriginalYarnrc(path) {
|
|
2284
|
+
try {
|
|
2285
|
+
const raw = await readFile(path, "utf8");
|
|
2286
|
+
return {
|
|
2287
|
+
raw,
|
|
2288
|
+
doc: parse(raw) ?? {}
|
|
2289
|
+
};
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
if (err.code === "ENOENT") return {
|
|
2292
|
+
raw: null,
|
|
2293
|
+
doc: {}
|
|
2294
|
+
};
|
|
2295
|
+
throw err;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
function buildRegistryBlock(envVar, url, alwaysAuth) {
|
|
2299
|
+
return {
|
|
2300
|
+
npmRegistryServer: url,
|
|
2301
|
+
npmAuthToken: `\${${envVar}}`,
|
|
2302
|
+
...alwaysAuth ? { npmAlwaysAuth: true } : {}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
async function applyYarnrcBerryConfig(args) {
|
|
2306
|
+
const registries = args.npmRegistries ?? [];
|
|
2307
|
+
const installEnvSecrets = args.installEnvSecrets ?? {};
|
|
2308
|
+
const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
|
|
2309
|
+
const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
|
|
2310
|
+
const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
|
|
2311
|
+
const cacheFolder = await mkdtemp(join(tmpdir(), "kici-yarn-berry-cache-"));
|
|
2312
|
+
const merged = {
|
|
2313
|
+
...doc,
|
|
2314
|
+
nodeLinker: "node-modules",
|
|
2315
|
+
enableGlobalCache: false,
|
|
2316
|
+
cacheFolder
|
|
2317
|
+
};
|
|
2318
|
+
const tokenEnv = {};
|
|
2319
|
+
const tokensForRedaction = [];
|
|
2320
|
+
if (hasPrivateRegistry) {
|
|
2321
|
+
merged.enableScripts = false;
|
|
2322
|
+
const npmScopes = { ...doc.npmScopes ?? {} };
|
|
2323
|
+
for (let i = 0; i < registries.length; i++) {
|
|
2324
|
+
const reg = registries[i];
|
|
2325
|
+
const envVar = tokenEnvName(args.jobIdShort, i);
|
|
2326
|
+
tokenEnv[envVar] = reg.token;
|
|
2327
|
+
tokensForRedaction.push(reg.token);
|
|
2328
|
+
const block = buildRegistryBlock(envVar, reg.url, reg.alwaysAuth);
|
|
2329
|
+
if (reg.scope) npmScopes[reg.scope] = block;
|
|
2330
|
+
else {
|
|
2331
|
+
merged.npmRegistryServer = reg.url;
|
|
2332
|
+
merged.npmAuthToken = block.npmAuthToken;
|
|
2333
|
+
if (reg.alwaysAuth) merged.npmAlwaysAuth = true;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
if (Object.keys(npmScopes).length > 0) merged.npmScopes = npmScopes;
|
|
2337
|
+
for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
|
|
2338
|
+
}
|
|
2339
|
+
await writeFile(yarnrcPath, stringify(merged), {
|
|
2340
|
+
encoding: "utf8",
|
|
2341
|
+
mode: 384
|
|
2342
|
+
});
|
|
2343
|
+
const cleanup = async () => {
|
|
2344
|
+
try {
|
|
2345
|
+
if (original === null) await unlink(yarnrcPath).catch(() => {});
|
|
2346
|
+
else await writeFile(yarnrcPath, original, { encoding: "utf8" });
|
|
2347
|
+
} catch {}
|
|
2348
|
+
await rm(cacheFolder, {
|
|
2349
|
+
recursive: true,
|
|
2350
|
+
force: true
|
|
2351
|
+
}).catch(() => {});
|
|
2352
|
+
};
|
|
2353
|
+
return {
|
|
2354
|
+
extraEnv: {
|
|
2355
|
+
...installEnvSecrets,
|
|
2356
|
+
...tokenEnv
|
|
2357
|
+
},
|
|
2358
|
+
tokensForRedaction,
|
|
2359
|
+
cleanup
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
1881
2362
|
const LOCAL_PROTOCOLS = [
|
|
1882
2363
|
"workspace:",
|
|
1883
2364
|
"file:",
|
|
@@ -1946,6 +2427,17 @@ async function fileExists$1(target) {
|
|
|
1946
2427
|
return false;
|
|
1947
2428
|
}
|
|
1948
2429
|
}
|
|
2430
|
+
/** Whether the repo-root package.json declares a non-empty `workspaces` array. */
|
|
2431
|
+
async function rootHasWorkspaces(repoRoot) {
|
|
2432
|
+
try {
|
|
2433
|
+
const ws = JSON.parse(await readFile(join(repoRoot, "package.json"), "utf-8")).workspaces;
|
|
2434
|
+
if (Array.isArray(ws)) return ws.length > 0;
|
|
2435
|
+
if (ws && typeof ws === "object" && Array.isArray(ws.packages)) return ws.packages.length > 0;
|
|
2436
|
+
return false;
|
|
2437
|
+
} catch {
|
|
2438
|
+
return false;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
1949
2441
|
/** Resolve a `file:`/`link:`/`portal:` spec to an absolute path under kiciDir. */
|
|
1950
2442
|
function resolveLocalPath(kiciDir, dep) {
|
|
1951
2443
|
const rawPath = dep.spec.slice(dep.protocol.length);
|
|
@@ -1960,8 +2452,31 @@ function isInsideRepo(repoRoot, target) {
|
|
|
1960
2452
|
* Classify each local-protocol dependency for the detected package manager and
|
|
1961
2453
|
* return the ones that are unresolvable in the agent's single-clone model.
|
|
1962
2454
|
*/
|
|
1963
|
-
async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
|
|
2455
|
+
async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot, yarnFlavor) {
|
|
1964
2456
|
if (packageManager === PackageManager.Npm) return [...deps];
|
|
2457
|
+
if (packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry) {
|
|
2458
|
+
const hasWorkspaces = await rootHasWorkspaces(repoRoot);
|
|
2459
|
+
const unresolvable = [];
|
|
2460
|
+
for (const dep of deps) {
|
|
2461
|
+
if (dep.protocol === "workspace:") {
|
|
2462
|
+
if (!hasWorkspaces) unresolvable.push(dep);
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
|
|
2466
|
+
}
|
|
2467
|
+
return unresolvable;
|
|
2468
|
+
}
|
|
2469
|
+
if (packageManager === PackageManager.Yarn) {
|
|
2470
|
+
const unresolvable = [];
|
|
2471
|
+
for (const dep of deps) {
|
|
2472
|
+
if (dep.protocol === "workspace:" || dep.protocol === "portal:") {
|
|
2473
|
+
unresolvable.push(dep);
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
|
|
2477
|
+
}
|
|
2478
|
+
return unresolvable;
|
|
2479
|
+
}
|
|
1965
2480
|
const hasWorkspaceFile = await fileExists$1(join(repoRoot, "pnpm-workspace.yaml"));
|
|
1966
2481
|
const unresolvable = [];
|
|
1967
2482
|
for (const dep of deps) {
|
|
@@ -1974,9 +2489,11 @@ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
|
|
|
1974
2489
|
return unresolvable;
|
|
1975
2490
|
}
|
|
1976
2491
|
/** Build the actionable error for unresolvable local-protocol dependencies. */
|
|
1977
|
-
function formatUnresolvableDepError(offenders, packageManager) {
|
|
2492
|
+
function formatUnresolvableDepError(offenders, packageManager, yarnFlavor) {
|
|
1978
2493
|
const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
|
|
1979
2494
|
if (packageManager === PackageManager.Npm) return `These .kici/ dependencies use local-protocol specifiers npm cannot resolve from a registry: ${list}. npm has no workspace protocol — pin a published version, publish the package to your registry, or use pnpm so an in-repo workspace sibling can be resolved.`;
|
|
2495
|
+
if (packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry) return `These .kici/ dependencies cannot be resolved by yarn berry from the cloned repository: ${list}. A workspace: dependency requires a "workspaces" array in the repo-root package.json, and file:/link:/portal: paths must stay inside this repository.`;
|
|
2496
|
+
if (packageManager === PackageManager.Yarn) return `These .kici/ dependencies use specifiers yarn classic cannot resolve: ${list}. yarn classic has no workspace: or portal: protocol — reference an in-repo sibling by a version range (yarn links matching workspace members), use pnpm, or keep file:/link: paths inside this repository. (yarn berry support requires a yarn@2+ packageManager field or a .yarnrc.yml.)`;
|
|
1980
2497
|
return `These .kici/ dependencies point outside the cloned repository, which the agent never has: ${list}. A workspace: dependency requires a pnpm-workspace.yaml at the repo root, and file:/link:/portal: paths must stay inside this repository.`;
|
|
1981
2498
|
}
|
|
1982
2499
|
/**
|
|
@@ -1990,9 +2507,105 @@ async function assertResolvableDeps(args) {
|
|
|
1990
2507
|
if (!pkg) return;
|
|
1991
2508
|
const localDeps = findLocalProtocolDeps(pkg);
|
|
1992
2509
|
if (localDeps.length === 0) return;
|
|
1993
|
-
const
|
|
2510
|
+
const flavor = args.yarnFlavor ?? YarnFlavor.Classic;
|
|
2511
|
+
const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot, flavor);
|
|
1994
2512
|
if (offenders.length === 0) return;
|
|
1995
|
-
throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
|
|
2513
|
+
throw new Error(formatUnresolvableDepError(offenders, args.packageManager, flavor));
|
|
2514
|
+
}
|
|
2515
|
+
//#endregion
|
|
2516
|
+
//#region src/execution/workspace-siblings.ts
|
|
2517
|
+
/**
|
|
2518
|
+
* In-repo workspace-sibling discovery for the agent's dependency handling.
|
|
2519
|
+
*
|
|
2520
|
+
* A pnpm or yarn-classic workspace lays out a `.kici/` member's `workspace:`
|
|
2521
|
+
* (pnpm) or version-range (yarn) siblings as symlinks pointing at package
|
|
2522
|
+
* directories that live inside the clone but outside `.kici/` and outside the
|
|
2523
|
+
* `node_modules` store. The dep-cache packer must travel those sibling dirs with
|
|
2524
|
+
* the closure (their symlinks would dangle otherwise), and the yarn install path
|
|
2525
|
+
* must build them (the install links a sibling but does not build it).
|
|
2526
|
+
*
|
|
2527
|
+
* `collectInRepoSiblings` walks a starting `node_modules` (and transitively each
|
|
2528
|
+
* discovered sibling's `node_modules`), returning each in-repo sibling directory
|
|
2529
|
+
* once, repo-root-relative, in breadth-first discovery order. The starting
|
|
2530
|
+
* `node_modules` is a parameter so it serves pnpm + yarn-standalone (seeded at
|
|
2531
|
+
* `.kici/node_modules`) and yarn-workspace-member (seeded at the hoisted root
|
|
2532
|
+
* `node_modules`).
|
|
2533
|
+
*/
|
|
2534
|
+
/**
|
|
2535
|
+
* The directory yarn lays `.kici`'s dependencies into. A standalone `.kici`
|
|
2536
|
+
* (own lockfile, no parent workspace) gets `.kici/node_modules`; a workspace
|
|
2537
|
+
* member hoists everything to the repo-root `node_modules`, leaving no
|
|
2538
|
+
* `.kici/node_modules`.
|
|
2539
|
+
*/
|
|
2540
|
+
function resolveYarnNodeModulesRoot(repoRoot, kiciDir) {
|
|
2541
|
+
const kiciNm = join(kiciDir, "node_modules");
|
|
2542
|
+
return existsSync(kiciNm) ? kiciNm : join(repoRoot, "node_modules");
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Walk `seedNodeModules` (and transitively each in-repo sibling's
|
|
2546
|
+
* `node_modules`) collecting the repo-root-relative directories of workspace
|
|
2547
|
+
* siblings — package dirs that live inside the clone but outside `.kici/` and
|
|
2548
|
+
* outside the repo-root `node_modules/` store. Returns each dir once, in
|
|
2549
|
+
* discovery (BFS) order.
|
|
2550
|
+
*/
|
|
2551
|
+
async function collectInRepoSiblings(workDir, kiciDir, seedNodeModules = join(kiciDir, "node_modules")) {
|
|
2552
|
+
const repoRoot = resolve(workDir);
|
|
2553
|
+
const kiciResolved = resolve(kiciDir);
|
|
2554
|
+
const rootNodeModules = resolve(join(workDir, "node_modules"));
|
|
2555
|
+
const found = /* @__PURE__ */ new Set();
|
|
2556
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2557
|
+
const queue = [seedNodeModules];
|
|
2558
|
+
while (queue.length > 0) {
|
|
2559
|
+
const nmDir = queue.shift();
|
|
2560
|
+
const real = await realpath(nmDir).catch(() => null);
|
|
2561
|
+
if (!real || visited.has(real)) continue;
|
|
2562
|
+
visited.add(real);
|
|
2563
|
+
for (const target of await resolveNodeModulesLinks(nmDir)) {
|
|
2564
|
+
if (!isInside(repoRoot, target)) continue;
|
|
2565
|
+
if (isInside(kiciResolved, target) || isInside(rootNodeModules, target)) continue;
|
|
2566
|
+
const rel = relative(workDir, target);
|
|
2567
|
+
if (!found.has(rel)) {
|
|
2568
|
+
found.add(rel);
|
|
2569
|
+
queue.push(join(target, "node_modules"));
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
return [...found];
|
|
2574
|
+
}
|
|
2575
|
+
/** Resolve every package symlink target under a `node_modules` dir (descending one level into `@scope` dirs). */
|
|
2576
|
+
async function resolveNodeModulesLinks(nmDir) {
|
|
2577
|
+
const targets = [];
|
|
2578
|
+
for (const entry of await readdir(nmDir).catch(() => [])) {
|
|
2579
|
+
if (entry.startsWith(".")) continue;
|
|
2580
|
+
const entryPath = join(nmDir, entry);
|
|
2581
|
+
if (entry.startsWith("@")) {
|
|
2582
|
+
for (const scoped of await readdir(entryPath).catch(() => [])) {
|
|
2583
|
+
const target = await resolveIfSymlink(join(entryPath, scoped));
|
|
2584
|
+
if (target) targets.push(target);
|
|
2585
|
+
}
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2588
|
+
const target = await resolveIfSymlink(entryPath);
|
|
2589
|
+
if (target) targets.push(target);
|
|
2590
|
+
}
|
|
2591
|
+
return targets;
|
|
2592
|
+
}
|
|
2593
|
+
/** Return the real path of `p` if it is a symlink, else null. */
|
|
2594
|
+
async function resolveIfSymlink(p) {
|
|
2595
|
+
try {
|
|
2596
|
+
if (!(await lstat(p)).isSymbolicLink()) return null;
|
|
2597
|
+
return await realpath(p);
|
|
2598
|
+
} catch {
|
|
2599
|
+
return null;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
/** Whether `target` is `root` itself or a path inside it. */
|
|
2603
|
+
function isInside(root, target) {
|
|
2604
|
+
const rel = relative(root, target);
|
|
2605
|
+
return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsoluteRel(rel);
|
|
2606
|
+
}
|
|
2607
|
+
function isAbsoluteRel(rel) {
|
|
2608
|
+
return rel.length > 1 && rel[1] === ":";
|
|
1996
2609
|
}
|
|
1997
2610
|
//#endregion
|
|
1998
2611
|
//#region src/execution/dep-installer.ts
|
|
@@ -2002,12 +2615,17 @@ async function assertResolvableDeps(args) {
|
|
|
2002
2615
|
* When the dep cache is unavailable or a download fails, the agent installs
|
|
2003
2616
|
* `.kici/` dependencies directly with the repository's package manager.
|
|
2004
2617
|
*
|
|
2005
|
-
* The package manager is detected from the cloned repo (npm / pnpm); the
|
|
2618
|
+
* The package manager is detected from the cloned repo (npm / pnpm / yarn); the
|
|
2006
2619
|
* presence of `.kici/package.json` signals that deps should be installed. npm
|
|
2007
2620
|
* is the default and ships with every Node.js install; pnpm is used when the
|
|
2008
2621
|
* repo is a pnpm workspace so a `.kici/` member can resolve in-repo
|
|
2009
|
-
* `workspace:` siblings. yarn is
|
|
2010
|
-
*
|
|
2622
|
+
* `workspace:` siblings. yarn is supported in both flavors: classic (v1) reads
|
|
2623
|
+
* `.kici/.npmrc` for registry auth and links version-range workspace siblings;
|
|
2624
|
+
* berry (v2+) reads a synthesized `.kici/.yarnrc.yml` for auth, runs with a
|
|
2625
|
+
* forced `nodeLinker: node-modules` (so the resulting tree matches classic/npm
|
|
2626
|
+
* and the runner's plain node resolution holds), and resolves
|
|
2627
|
+
* `workspace:`/`portal:` siblings. Either flavor links the sibling but does not
|
|
2628
|
+
* build it, so the agent builds the in-repo closure after install.
|
|
2011
2629
|
*
|
|
2012
2630
|
* Security: the install runs with an isolated per-invocation cache/store
|
|
2013
2631
|
* directory to prevent cache poisoning across build jobs — a malicious
|
|
@@ -2033,6 +2651,15 @@ async function detectKiciPackageManager(repoRoot, kiciDir) {
|
|
|
2033
2651
|
return await detectPackageManagerFromManifests(repoRoot) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
|
|
2034
2652
|
}
|
|
2035
2653
|
/**
|
|
2654
|
+
* Detect the yarn flavor (classic vs berry) for the cloned repo. Mirrors
|
|
2655
|
+
* `detectKiciPackageManager`: probe the repo root first, then `.kici/` for a
|
|
2656
|
+
* standalone project. Only called when the detected manager is `Yarn`.
|
|
2657
|
+
*/
|
|
2658
|
+
async function detectKiciYarnFlavor(repoRoot, kiciDir) {
|
|
2659
|
+
if (await detectYarnFlavor(repoRoot) === YarnFlavor.Berry) return YarnFlavor.Berry;
|
|
2660
|
+
return detectYarnFlavor(kiciDir);
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2036
2663
|
* Install `.kici/` dependencies inline with the repo's package manager.
|
|
2037
2664
|
*
|
|
2038
2665
|
* Falls back to this when the dep cache is unavailable or a download fails.
|
|
@@ -2051,20 +2678,28 @@ async function detectKiciPackageManager(repoRoot, kiciDir) {
|
|
|
2051
2678
|
async function installDeps(kiciDir, opts = {}) {
|
|
2052
2679
|
const repoRoot = opts.repoRoot ?? dirname(kiciDir);
|
|
2053
2680
|
const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
|
|
2681
|
+
const yarnFlavor = packageManager === PackageManager.Yarn ? await detectKiciYarnFlavor(repoRoot, kiciDir) : YarnFlavor.Classic;
|
|
2054
2682
|
logger$2.info("Installing deps inline", {
|
|
2055
2683
|
packageManager,
|
|
2684
|
+
yarnFlavor,
|
|
2056
2685
|
dir: kiciDir
|
|
2057
2686
|
});
|
|
2058
|
-
process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
|
|
2059
|
-
if (packageManager === PackageManager.Yarn) throw new Error("This repository uses yarn, which the KiCI agent does not yet support for .kici/ dependency installation. Use npm or pnpm for the .kici/ project, or open a feature request for yarn support.");
|
|
2687
|
+
process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, flavor=${yarnFlavor}, cwd=${kiciDir}\n`);
|
|
2060
2688
|
await assertResolvableDeps({
|
|
2061
2689
|
kiciDir,
|
|
2062
2690
|
repoRoot,
|
|
2063
|
-
packageManager
|
|
2691
|
+
packageManager,
|
|
2692
|
+
yarnFlavor
|
|
2064
2693
|
});
|
|
2065
2694
|
const startTime = Date.now();
|
|
2066
2695
|
const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
|
|
2067
|
-
const
|
|
2696
|
+
const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
|
|
2697
|
+
const registryConfig = isBerry ? await applyYarnrcBerryConfig({
|
|
2698
|
+
kiciDir,
|
|
2699
|
+
npmRegistries: opts.npmRegistries,
|
|
2700
|
+
installEnvSecrets: opts.installEnvSecrets,
|
|
2701
|
+
jobIdShort: opts.jobIdShort ?? "00000000"
|
|
2702
|
+
}) : await applyNpmRegistryConfig({
|
|
2068
2703
|
kiciDir,
|
|
2069
2704
|
npmRegistries: opts.npmRegistries,
|
|
2070
2705
|
installEnvSecrets: opts.installEnvSecrets,
|
|
@@ -2076,6 +2711,15 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
2076
2711
|
hasPrivateRegistry,
|
|
2077
2712
|
registryConfig
|
|
2078
2713
|
});
|
|
2714
|
+
else if (isBerry) await runYarnBerryInstall({
|
|
2715
|
+
kiciDir,
|
|
2716
|
+
registryConfig
|
|
2717
|
+
});
|
|
2718
|
+
else if (packageManager === PackageManager.Yarn) await runYarnInstall({
|
|
2719
|
+
kiciDir,
|
|
2720
|
+
hasPrivateRegistry,
|
|
2721
|
+
registryConfig
|
|
2722
|
+
});
|
|
2079
2723
|
else await runNpmInstall({
|
|
2080
2724
|
kiciDir,
|
|
2081
2725
|
hasPrivateRegistry,
|
|
@@ -2090,6 +2734,7 @@ async function installDeps(kiciDir, opts = {}) {
|
|
|
2090
2734
|
await registryConfig.cleanup();
|
|
2091
2735
|
}
|
|
2092
2736
|
if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
|
|
2737
|
+
if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
|
|
2093
2738
|
const durationMs = Date.now() - startTime;
|
|
2094
2739
|
process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
|
|
2095
2740
|
logger$2.info("Deps installed inline", {
|
|
@@ -2177,6 +2822,131 @@ async function runPnpmInstall(args) {
|
|
|
2177
2822
|
}).catch(() => {});
|
|
2178
2823
|
}
|
|
2179
2824
|
}
|
|
2825
|
+
/** Pure: argv for `yarn install` with an isolated cache folder. */
|
|
2826
|
+
function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
|
|
2827
|
+
const a = [
|
|
2828
|
+
"install",
|
|
2829
|
+
"--cache-folder",
|
|
2830
|
+
cacheDir,
|
|
2831
|
+
"--non-interactive",
|
|
2832
|
+
"--no-progress"
|
|
2833
|
+
];
|
|
2834
|
+
if (hasPrivateRegistry) a.push("--ignore-scripts");
|
|
2835
|
+
return a;
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Run `yarn install` from `.kici/` with an isolated cache folder. yarn classic
|
|
2839
|
+
* reads the synthesized `.kici/.npmrc` (registry + `${VAR}` token expansion) for
|
|
2840
|
+
* private-registry auth. A workspace member hoists deps to the repo-root
|
|
2841
|
+
* node_modules; a standalone `.kici` gets `.kici/node_modules`. Not
|
|
2842
|
+
* `--frozen-lockfile` (resolved URLs in the lockfile may point at a different
|
|
2843
|
+
* registry than the synthesized `.npmrc`, e.g. localhost tunnel vs direct IP).
|
|
2844
|
+
*/
|
|
2845
|
+
async function runYarnInstall(args) {
|
|
2846
|
+
await assertYarnAvailable();
|
|
2847
|
+
const { nodeDir } = resolveNpm();
|
|
2848
|
+
const cacheDir = await mkdtemp(join(tmpdir(), "kici-yarn-cache-"));
|
|
2849
|
+
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
2850
|
+
const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
|
|
2851
|
+
try {
|
|
2852
|
+
process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
|
|
2853
|
+
await execFileAsync("yarn", argv, {
|
|
2854
|
+
cwd: args.kiciDir,
|
|
2855
|
+
env,
|
|
2856
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
2857
|
+
maxBuffer: INSTALL_MAX_BUFFER
|
|
2858
|
+
});
|
|
2859
|
+
} finally {
|
|
2860
|
+
await rm(cacheDir, {
|
|
2861
|
+
recursive: true,
|
|
2862
|
+
force: true
|
|
2863
|
+
}).catch(() => {});
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
/** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
|
|
2867
|
+
function buildYarnBerryInstallArgs() {
|
|
2868
|
+
return ["install"];
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Run a berry `yarn install` from `.kici/`. The synthesized `.kici/.yarnrc.yml`
|
|
2872
|
+
* (applied by `applyYarnrcBerryConfig`) forces `nodeLinker: node-modules`, an
|
|
2873
|
+
* isolated `cacheFolder`, and — when a private registry is configured —
|
|
2874
|
+
* `enableScripts: false` + `npmScopes`/`npmRegistryServer` auth. corepack
|
|
2875
|
+
* provisions the repo-pinned berry version; `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
|
|
2876
|
+
* makes that non-interactive. Not `--immutable` (resolved URLs in the lockfile
|
|
2877
|
+
* may point at a different registry than the synthesized config).
|
|
2878
|
+
*/
|
|
2879
|
+
async function runYarnBerryInstall(args) {
|
|
2880
|
+
await assertYarnAvailable();
|
|
2881
|
+
const { nodeDir } = resolveNpm();
|
|
2882
|
+
const env = {
|
|
2883
|
+
...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
|
|
2884
|
+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
|
2885
|
+
};
|
|
2886
|
+
const argv = buildYarnBerryInstallArgs();
|
|
2887
|
+
process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")} (berry)\n`);
|
|
2888
|
+
await execFileAsync("yarn", argv, {
|
|
2889
|
+
cwd: args.kiciDir,
|
|
2890
|
+
env,
|
|
2891
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
2892
|
+
maxBuffer: INSTALL_MAX_BUFFER
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
/** Throw an actionable error when the repo needs yarn but it is not installed. */
|
|
2896
|
+
async function assertYarnAvailable() {
|
|
2897
|
+
try {
|
|
2898
|
+
await execFileAsync("yarn", ["--version"], {
|
|
2899
|
+
timeout: 3e4,
|
|
2900
|
+
cwd: tmpdir()
|
|
2901
|
+
});
|
|
2902
|
+
} catch (e) {
|
|
2903
|
+
throw new Error(`This repository uses yarn, but yarn is not available on this agent. Install yarn (e.g. \`corepack enable\`) or run on a container/Firecracker agent that bundles it. (${toErrorMessage(e)})`);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Build the in-repo workspace siblings `.kici` depends on (yarn links them on
|
|
2908
|
+
* install but does not build them). Walks siblings from the resolved
|
|
2909
|
+
* node_modules root and runs each sibling's `build` script in leaf-first
|
|
2910
|
+
* (reverse-discovery) order with a clean env (no synthesized registry tokens).
|
|
2911
|
+
* Deep cross-sibling build chains may build out of strict topological order —
|
|
2912
|
+
* real `.kici` closures are shallow.
|
|
2913
|
+
*/
|
|
2914
|
+
async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
|
|
2915
|
+
const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
|
|
2916
|
+
if (siblings.length === 0) return;
|
|
2917
|
+
const { nodeDir } = resolveNpm();
|
|
2918
|
+
const env = envWithNodeOnPath({}, nodeDir);
|
|
2919
|
+
for (const rel of [...siblings].reverse()) {
|
|
2920
|
+
const sibDir = join(repoRoot, rel);
|
|
2921
|
+
if (!await siblingHasBuildScript(sibDir)) continue;
|
|
2922
|
+
const [argv, cwd] = yarnFlavor === YarnFlavor.Berry ? [["run", "build"], sibDir] : [[
|
|
2923
|
+
"--cwd",
|
|
2924
|
+
sibDir,
|
|
2925
|
+
"run",
|
|
2926
|
+
"build"
|
|
2927
|
+
], repoRoot];
|
|
2928
|
+
process.stderr.write(`[dep-installer:trace] building yarn sibling (${yarnFlavor}): yarn ${argv.join(" ")} @ ${cwd}\n`);
|
|
2929
|
+
try {
|
|
2930
|
+
await execFileAsync("yarn", argv, {
|
|
2931
|
+
cwd,
|
|
2932
|
+
env,
|
|
2933
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
2934
|
+
maxBuffer: INSTALL_MAX_BUFFER
|
|
2935
|
+
});
|
|
2936
|
+
} catch (e) {
|
|
2937
|
+
logSubprocessStreams(e, []);
|
|
2938
|
+
throw new Error(`Failed to build .kici yarn workspace sibling ${rel}: ${describeExecError(e)}`);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/** Whether a sibling package.json declares a `build` script. */
|
|
2943
|
+
async function siblingHasBuildScript(sibDir) {
|
|
2944
|
+
try {
|
|
2945
|
+
return typeof JSON.parse(await readFile(join(sibDir, "package.json"), "utf-8")).scripts?.build === "string";
|
|
2946
|
+
} catch {
|
|
2947
|
+
return false;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2180
2950
|
/**
|
|
2181
2951
|
* Build the in-repo dependency closure of the `.kici/` package so a
|
|
2182
2952
|
* `workspace:` sibling's build output exists before the workflow that imports
|
|
@@ -2220,7 +2990,10 @@ function describeExecError(e) {
|
|
|
2220
2990
|
/** Throw an actionable error when the repo needs pnpm but it is not installed. */
|
|
2221
2991
|
async function assertPnpmAvailable() {
|
|
2222
2992
|
try {
|
|
2223
|
-
await execFileAsync("pnpm", ["--version"], {
|
|
2993
|
+
await execFileAsync("pnpm", ["--version"], {
|
|
2994
|
+
timeout: 3e4,
|
|
2995
|
+
cwd: tmpdir()
|
|
2996
|
+
});
|
|
2224
2997
|
} catch (e) {
|
|
2225
2998
|
throw new Error(`This repository is a pnpm workspace, but pnpm is not available on this agent. Install pnpm (e.g. \`corepack enable\`) or run on a container/ Firecracker agent that bundles it. (${toErrorMessage(e)})`);
|
|
2226
2999
|
}
|
|
@@ -2240,8 +3013,8 @@ function logSubprocessStreams(e, tokens) {
|
|
|
2240
3013
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
2241
3014
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
2242
3015
|
*/
|
|
2243
|
-
const AGENT_SDK_VERSION = "0.1.
|
|
2244
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
3016
|
+
const AGENT_SDK_VERSION = "0.1.18";
|
|
3017
|
+
const AGENT_SDK_BUNDLE_HASH = "8308089347c304e41b457d3867b17bbff11d6b5cd9706b6823e7abdbd849f33f";
|
|
2245
3018
|
/**
|
|
2246
3019
|
* Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
|
|
2247
3020
|
* subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
|
|
@@ -2364,18 +3137,20 @@ function extractSteps(workflow, jobName) {
|
|
|
2364
3137
|
* A sibling mismatch logs a warning; a missing target job throws a clear
|
|
2365
3138
|
* determinism error.
|
|
2366
3139
|
*/
|
|
2367
|
-
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames) {
|
|
3140
|
+
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds) {
|
|
2368
3141
|
const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
|
|
2369
3142
|
const { $ } = await import("zx");
|
|
2370
3143
|
const { createLogger } = await import("@kici-dev/shared");
|
|
2371
|
-
const { buildKiciApi } = await import("@kici-dev/sdk");
|
|
3144
|
+
const { buildKiciApi, buildNeedsContext } = await import("@kici-dev/sdk");
|
|
2372
3145
|
const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
|
|
2373
3146
|
const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
|
|
3147
|
+
const needs = upstreamSnapshot ? buildNeedsContext(upstreamSnapshot, declaredNeeds ?? []) : void 0;
|
|
2374
3148
|
const generatedJobs = await dynamicFn({
|
|
2375
3149
|
$,
|
|
2376
3150
|
ctx: {
|
|
2377
3151
|
workflow: { name: workflow.name },
|
|
2378
|
-
event
|
|
3152
|
+
event,
|
|
3153
|
+
...needs && { needs }
|
|
2379
3154
|
},
|
|
2380
3155
|
log,
|
|
2381
3156
|
env,
|
|
@@ -2424,7 +3199,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
2424
3199
|
* runs without a preceding build (cache infrastructure unavailable, or a
|
|
2425
3200
|
* build job that failed but left dynamic dispatch in flight).
|
|
2426
3201
|
*/
|
|
2427
|
-
init_download();
|
|
2428
3202
|
init_dep_restore();
|
|
2429
3203
|
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
2430
3204
|
async function extractSourceTarball(data, targetDir) {
|
|
@@ -2470,18 +3244,17 @@ async function restoreSource(workDir, sourceTarUrl) {
|
|
|
2470
3244
|
init_download();
|
|
2471
3245
|
const logger = createLogger({ prefix: "overlay-applier" });
|
|
2472
3246
|
const IV_LENGTH = 12;
|
|
2473
|
-
const AUTH_TAG_LENGTH = 16;
|
|
2474
3247
|
/**
|
|
2475
3248
|
* Decrypt an encrypted buffer using AES-256-GCM.
|
|
2476
3249
|
*
|
|
2477
3250
|
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
2478
3251
|
*/
|
|
2479
3252
|
function decryptBuffer(encrypted, aesKey) {
|
|
2480
|
-
if (encrypted.length <
|
|
3253
|
+
if (encrypted.length < 28) throw new Error(`Tarball decryption failed: encrypted data too short (${encrypted.length} bytes, minimum 28 bytes)`);
|
|
2481
3254
|
const iv = encrypted.subarray(0, IV_LENGTH);
|
|
2482
|
-
const authTag = encrypted.subarray(IV_LENGTH,
|
|
2483
|
-
const ciphertext = encrypted.subarray(
|
|
2484
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
3255
|
+
const authTag = encrypted.subarray(IV_LENGTH, 28);
|
|
3256
|
+
const ciphertext = encrypted.subarray(28);
|
|
3257
|
+
const decipher = crypto$1.createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
2485
3258
|
decipher.setAuthTag(authTag);
|
|
2486
3259
|
try {
|
|
2487
3260
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
@@ -2601,7 +3374,9 @@ async function applyOverlay(config) {
|
|
|
2601
3374
|
* This file is compiled alongside the agent by rolldown (existing build), but
|
|
2602
3375
|
* runs as a SEPARATE process spawned by the sandbox backend.
|
|
2603
3376
|
*/
|
|
3377
|
+
init_download();
|
|
2604
3378
|
init_dep_restore();
|
|
3379
|
+
const AGENT_VERSION = "0.1.18";
|
|
2605
3380
|
process.on("uncaughtException", (err) => {
|
|
2606
3381
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
2607
3382
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -2911,6 +3686,33 @@ function waitForApiResponse(requestId) {
|
|
|
2911
3686
|
const pendingCacheResponses = /* @__PURE__ */ new Map();
|
|
2912
3687
|
/** Default timeout for a cache request relay (matches the upload-URL request budget). */
|
|
2913
3688
|
const CACHE_RESPONSE_TIMEOUT_MS = 3e4;
|
|
3689
|
+
/**
|
|
3690
|
+
* Pending promises for provenance.response messages from the agent.
|
|
3691
|
+
* Key: requestId (correlates provenance.request -> provenance.response).
|
|
3692
|
+
*/
|
|
3693
|
+
const pendingProvenanceResponses = /* @__PURE__ */ new Map();
|
|
3694
|
+
/** Wait for a provenance.response from the agent with the given requestId. */
|
|
3695
|
+
function waitForProvenanceResponse(requestId) {
|
|
3696
|
+
return new Promise((resolve, reject) => {
|
|
3697
|
+
const timer = setTimeout(() => {
|
|
3698
|
+
pendingProvenanceResponses.delete(requestId);
|
|
3699
|
+
reject(/* @__PURE__ */ new Error(`Provenance request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
|
|
3700
|
+
}, CACHE_RESPONSE_TIMEOUT_MS);
|
|
3701
|
+
pendingProvenanceResponses.set(requestId, {
|
|
3702
|
+
resolve: (response) => {
|
|
3703
|
+
clearTimeout(timer);
|
|
3704
|
+
pendingProvenanceResponses.delete(requestId);
|
|
3705
|
+
resolve(response);
|
|
3706
|
+
},
|
|
3707
|
+
reject: (err) => {
|
|
3708
|
+
clearTimeout(timer);
|
|
3709
|
+
pendingProvenanceResponses.delete(requestId);
|
|
3710
|
+
reject(err);
|
|
3711
|
+
},
|
|
3712
|
+
timer
|
|
3713
|
+
});
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
2914
3716
|
/** Wait for a cache.response from the agent with the given requestId. */
|
|
2915
3717
|
function waitForCacheResponse(requestId) {
|
|
2916
3718
|
return new Promise((resolve, reject) => {
|
|
@@ -3051,6 +3853,64 @@ function buildCacheTransport() {
|
|
|
3051
3853
|
}
|
|
3052
3854
|
};
|
|
3053
3855
|
}
|
|
3856
|
+
/** Send a `provenance.request` IPC and await the matching `provenance.response`. */
|
|
3857
|
+
async function relayProvenanceIpc(request) {
|
|
3858
|
+
const requestId = randomUUID();
|
|
3859
|
+
sendMessage({
|
|
3860
|
+
type: "provenance.request",
|
|
3861
|
+
requestId,
|
|
3862
|
+
...request
|
|
3863
|
+
});
|
|
3864
|
+
const response = await waitForProvenanceResponse(requestId);
|
|
3865
|
+
if (response.error) throw new Error(`Provenance relay failed: ${response.error}`);
|
|
3866
|
+
return response;
|
|
3867
|
+
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Build the `ctx.attestProvenance` step helper. Resolves a `path` subject to a
|
|
3870
|
+
* SHA-256 digest, threads the identity token via the supplied OIDC getter, and
|
|
3871
|
+
* persists the bundle over the IPC -> WS provenance-upload relay.
|
|
3872
|
+
*/
|
|
3873
|
+
function buildAttestProvenanceFn(request, workDir, getIdToken) {
|
|
3874
|
+
return async (opts) => {
|
|
3875
|
+
const subject = provenanceSubjectIsPath(opts.subject) ? {
|
|
3876
|
+
name: opts.subject.name,
|
|
3877
|
+
digest: { sha256: await sha256File$1(join(workDir, opts.subject.path)) }
|
|
3878
|
+
} : {
|
|
3879
|
+
name: opts.subject.name,
|
|
3880
|
+
digest: opts.subject.digest
|
|
3881
|
+
};
|
|
3882
|
+
const result = await attestProvenance({
|
|
3883
|
+
getIdToken,
|
|
3884
|
+
builderVersions: {
|
|
3885
|
+
"kici-agent": AGENT_VERSION,
|
|
3886
|
+
"kici-orchestrator": "unknown"
|
|
3887
|
+
},
|
|
3888
|
+
persist: async (bundle, subjectDigest) => {
|
|
3889
|
+
const urlResponse = await relayProvenanceIpc({
|
|
3890
|
+
op: "requestUploadUrl",
|
|
3891
|
+
subjectDigest
|
|
3892
|
+
});
|
|
3893
|
+
if (!urlResponse.uploadUrl) throw new Error("Orchestrator returned no provenance upload URL");
|
|
3894
|
+
await uploadToPresignedUrl(urlResponse.uploadUrl, Buffer.from(JSON.stringify(bundle)));
|
|
3895
|
+
await relayProvenanceIpc({
|
|
3896
|
+
op: "complete",
|
|
3897
|
+
subjectDigest,
|
|
3898
|
+
subjectName: subject.name,
|
|
3899
|
+
mediaType: bundle.mediaType
|
|
3900
|
+
});
|
|
3901
|
+
return `provenance/${request.runId}/${request.jobId}/${subjectDigest}.kici.json`;
|
|
3902
|
+
}
|
|
3903
|
+
}, {
|
|
3904
|
+
subject,
|
|
3905
|
+
...opts.audience !== void 0 && { audience: opts.audience }
|
|
3906
|
+
});
|
|
3907
|
+
return {
|
|
3908
|
+
storageKey: result.storageKey,
|
|
3909
|
+
subjectDigest: result.subjectDigest,
|
|
3910
|
+
bundleMediaType: result.bundle.mediaType
|
|
3911
|
+
};
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3054
3914
|
/**
|
|
3055
3915
|
* Build the declarative-cache phase dependencies and run the job-level cache
|
|
3056
3916
|
* restore (Phase 9b).
|
|
@@ -3108,6 +3968,9 @@ function dispatchAgentMessage(msg) {
|
|
|
3108
3968
|
} else if (msg.type === "cache.response") {
|
|
3109
3969
|
const pending = pendingCacheResponses.get(msg.requestId);
|
|
3110
3970
|
if (pending) pending.resolve(msg);
|
|
3971
|
+
} else if (msg.type === "provenance.response") {
|
|
3972
|
+
const pending = pendingProvenanceResponses.get(msg.requestId);
|
|
3973
|
+
if (pending) pending.resolve(msg);
|
|
3111
3974
|
} else if (msg.type === "approval.resolved") {
|
|
3112
3975
|
const pending = pendingApprovalResolutions.get(msg.requestId);
|
|
3113
3976
|
if (pending) pending.resolve(msg);
|
|
@@ -3289,10 +4152,22 @@ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
|
3289
4152
|
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
3290
4153
|
* inside this process with full shell access.
|
|
3291
4154
|
*/
|
|
3292
|
-
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
|
|
4155
|
+
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker) {
|
|
3293
4156
|
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
3294
4157
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
3295
4158
|
const rawPayload = rawPayloadFromEvent(request.event);
|
|
4159
|
+
const kici = buildKiciApi(async (method, params) => {
|
|
4160
|
+
const reqId = randomUUID();
|
|
4161
|
+
sendMessage({
|
|
4162
|
+
type: "agent.api.request",
|
|
4163
|
+
requestId: reqId,
|
|
4164
|
+
method,
|
|
4165
|
+
params: params ?? {}
|
|
4166
|
+
});
|
|
4167
|
+
const result = await waitForApiResponse(reqId);
|
|
4168
|
+
if (method === OIDC_TOKEN_REQUEST_METHOD && result && typeof result.token === "string") masker.registerSecrets({ __oidc_token__: result.token });
|
|
4169
|
+
return result;
|
|
4170
|
+
}, { jobId: request.jobId });
|
|
3296
4171
|
return {
|
|
3297
4172
|
$: step$,
|
|
3298
4173
|
log,
|
|
@@ -3344,18 +4219,13 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
3344
4219
|
setSecretOutput: (key, value) => {
|
|
3345
4220
|
secretOutputs.set(key, value);
|
|
3346
4221
|
},
|
|
3347
|
-
kici
|
|
3348
|
-
|
|
3349
|
-
sendMessage({
|
|
3350
|
-
type: "agent.api.request",
|
|
3351
|
-
requestId: reqId,
|
|
3352
|
-
method,
|
|
3353
|
-
params: params ?? {}
|
|
3354
|
-
});
|
|
3355
|
-
return waitForApiResponse(reqId);
|
|
3356
|
-
}),
|
|
4222
|
+
kici,
|
|
4223
|
+
attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
|
|
3357
4224
|
...rawPayload && { rawPayload },
|
|
3358
|
-
...request.provider && { provider: request.provider }
|
|
4225
|
+
...request.provider && { provider: request.provider },
|
|
4226
|
+
...request.matrixValues && { matrix: request.matrixValues },
|
|
4227
|
+
...request.host && { host: request.host },
|
|
4228
|
+
...request.agent && { agent: request.agent }
|
|
3359
4229
|
};
|
|
3360
4230
|
}
|
|
3361
4231
|
/** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
|
|
@@ -3498,6 +4368,31 @@ async function applyOverlayIfRequested(request, workflowDir) {
|
|
|
3498
4368
|
trace(`overlay applied: ${overlayResult.filesApplied} files, ${overlayResult.filesDeleted} deletions`);
|
|
3499
4369
|
}
|
|
3500
4370
|
/**
|
|
4371
|
+
* Phase 1c — Make git usable in a full-repo overlay workspace.
|
|
4372
|
+
*
|
|
4373
|
+
* `kici run remote` uploads the developer's working tree (including `.git`) as
|
|
4374
|
+
* a self-contained overlay; no clone happens, so the extracted `.git` directory
|
|
4375
|
+
* is owned by whatever UID wrote the tarball. Under rootless podman that UID may
|
|
4376
|
+
* not match the container UID, which trips git's "dubious ownership" /
|
|
4377
|
+
* `safe.directory` check and makes every step `git` command fail.
|
|
4378
|
+
*
|
|
4379
|
+
* Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
|
|
4380
|
+
* `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
|
|
4381
|
+
* on `process.env` here (before the step loop) means every step subprocess —
|
|
4382
|
+
* each zx `$` snapshots `process.env` at context creation — inherits it, so git
|
|
4383
|
+
* works in steps exactly as it does locally. We also register the dep-restore
|
|
4384
|
+
* scratch-dir exclude now that a real `.git` exists in the workspace.
|
|
4385
|
+
*/
|
|
4386
|
+
async function makeOverlayGitUsable(request, workspaceDir) {
|
|
4387
|
+
if (!request.fullRepo) return;
|
|
4388
|
+
if (!existsSync(join(workspaceDir, ".git"))) return;
|
|
4389
|
+
const cfgPath = join(await fsPromises.mkdtemp(join(tmpdir(), "kici-gitcfg-")), "config");
|
|
4390
|
+
await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
4391
|
+
process.env.GIT_CONFIG_GLOBAL = cfgPath;
|
|
4392
|
+
trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
|
|
4393
|
+
await excludeScratchFromGit(workspaceDir);
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
3501
4396
|
* Phase 2 — Restore deps from cache (with hash-mismatch hard-fail) OR fall
|
|
3502
4397
|
* back to inline install. Skipped when `.kici/package.json` doesn't exist.
|
|
3503
4398
|
* For global workflows deps come from the workflow repo (where `.kici/` lives).
|
|
@@ -3881,7 +4776,7 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
|
3881
4776
|
let rawSteps;
|
|
3882
4777
|
let driftDroppedJobs = [];
|
|
3883
4778
|
if (request.dynamicSource) {
|
|
3884
|
-
const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames);
|
|
4779
|
+
const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames, request.dynamicSource.upstreamSnapshot, request.dynamicSource.declaredNeeds);
|
|
3885
4780
|
rawSteps = dynamicResult.steps;
|
|
3886
4781
|
driftDroppedJobs = dynamicResult.droppedJobs;
|
|
3887
4782
|
if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
|
|
@@ -3980,15 +4875,6 @@ function collectJobHooks(job) {
|
|
|
3980
4875
|
return jobHooks;
|
|
3981
4876
|
}
|
|
3982
4877
|
/**
|
|
3983
|
-
* Normalize `Job.init` (config | config[] | false | undefined) to an ordered
|
|
3984
|
-
* array of init specs. `false` is an explicit opt-out and `undefined` (no
|
|
3985
|
-
* config) both resolve to an empty list — the init phase is then a no-op.
|
|
3986
|
-
*/
|
|
3987
|
-
function resolveInitSpecs(job) {
|
|
3988
|
-
if (!job || job.init === void 0 || job.init === false) return [];
|
|
3989
|
-
return Array.isArray(job.init) ? [...job.init] : [job.init];
|
|
3990
|
-
}
|
|
3991
|
-
/**
|
|
3992
4878
|
* Base stepIndex for the `init:<n>` pseudo-steps. The step loop reserves the
|
|
3993
4879
|
* range starting at `steps.length` for hook pseudo-steps (`beforeStep` =
|
|
3994
4880
|
* `steps.length + i*2`, `afterStep` = `steps.length + i*2 + 1`, and job-level
|
|
@@ -4064,7 +4950,16 @@ function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
|
|
|
4064
4950
|
*/
|
|
4065
4951
|
async function runInitPhaseOrFailJob(args) {
|
|
4066
4952
|
const { job, stepCwd, envFiles, operatorSecretKeys, maskedSend } = args;
|
|
4067
|
-
const
|
|
4953
|
+
const directives = normalizeInitItems(job);
|
|
4954
|
+
if (directives.length === 0) return;
|
|
4955
|
+
const initSpecs = await expandInitDirectives(directives, {
|
|
4956
|
+
cloneRoot: stepCwd,
|
|
4957
|
+
log: (line) => maskedSend({
|
|
4958
|
+
type: "log.line",
|
|
4959
|
+
stepIndex: -1,
|
|
4960
|
+
line
|
|
4961
|
+
})
|
|
4962
|
+
});
|
|
4068
4963
|
if (initSpecs.length === 0) return;
|
|
4069
4964
|
const initResult = await runInitPhase({
|
|
4070
4965
|
specs: initSpecs,
|
|
@@ -4139,6 +5034,7 @@ async function main() {
|
|
|
4139
5034
|
});
|
|
4140
5035
|
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
4141
5036
|
await applyOverlayIfRequested(request, workflowDir);
|
|
5037
|
+
await makeOverlayGitUsable(request, workflowDir);
|
|
4142
5038
|
if (aborted) abortAndExit("aborted after clone");
|
|
4143
5039
|
await installDependenciesIfNeeded(workflowDir, request);
|
|
4144
5040
|
if (aborted) abortAndExit("aborted after deps");
|
|
@@ -4182,7 +5078,7 @@ async function main() {
|
|
|
4182
5078
|
const handle = buildStepSecrets(request, masker, () => {});
|
|
4183
5079
|
currentStepSecrets = handle.secrets;
|
|
4184
5080
|
currentStepDispose = handle.dispose;
|
|
4185
|
-
const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets);
|
|
5081
|
+
const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker);
|
|
4186
5082
|
if (globalRepoInfo) {
|
|
4187
5083
|
ctx.workflowRepo = globalRepoInfo.workflowRepo;
|
|
4188
5084
|
ctx.sourceRepo = globalRepoInfo.sourceRepo;
|
|
@@ -4306,6 +5202,6 @@ main().catch((error) => {
|
|
|
4306
5202
|
setTimeout(() => process.exit(1), 100);
|
|
4307
5203
|
});
|
|
4308
5204
|
//#endregion
|
|
4309
|
-
export {
|
|
5205
|
+
export { createSandboxStepContext, rawPayloadFromEvent };
|
|
4310
5206
|
|
|
4311
5207
|
//# sourceMappingURL=workflow-runner.js.map
|