@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.
Files changed (30) hide show
  1. package/dist/execution/dep-installer.d.ts +12 -3
  2. package/dist/execution/dep-packer.d.ts +9 -1
  3. package/dist/execution/env-init/presets/directives.d.ts +20 -0
  4. package/dist/execution/env-init/presets/expand.d.ts +16 -0
  5. package/dist/execution/env-init/presets/mise/cache-key.d.ts +7 -0
  6. package/dist/execution/env-init/presets/mise/expander.d.ts +16 -0
  7. package/dist/execution/env-init/presets/mise/templates.d.ts +16 -0
  8. package/dist/execution/env-init/presets/mise/windows-install.d.ts +18 -0
  9. package/dist/execution/env-init/presets/registry.d.ts +31 -0
  10. package/dist/execution/init-runner.d.ts +7 -0
  11. package/dist/execution/job-runner.d.ts +9 -1
  12. package/dist/execution/sandbox/env-delta.d.ts +2 -0
  13. package/dist/execution/sandbox/index.d.ts +1 -1
  14. package/dist/execution/sandbox/ipc-protocol.d.ts +81 -3
  15. package/dist/execution/sandbox/types.d.ts +9 -1
  16. package/dist/execution/sandbox/workflow-runner.d.ts +12 -7
  17. package/dist/execution/validate-kici-deps.d.ts +10 -2
  18. package/dist/execution/workflow-loader.d.ts +5 -1
  19. package/dist/execution/workspace-siblings.d.ts +33 -0
  20. package/dist/execution/yarnrc-berry-config.d.ts +23 -0
  21. package/dist/index.js +423 -17
  22. package/dist/provenance/attest.d.ts +30 -0
  23. package/dist/provenance/sign.d.ts +21 -0
  24. package/dist/provenance/statement-builder.d.ts +38 -0
  25. package/dist/server.js +703 -160
  26. package/dist/version.d.ts +2 -0
  27. package/dist/workflow-runner.js +955 -59
  28. package/dist/ws/orchestrator-client.d.ts +16 -1
  29. package/package.json +14 -12
  30. package/sbom.spdx.json +2526 -5994
@@ -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
- for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}:${target.PATH}` : dir;
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 offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot);
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 detected but not yet supported and is
2010
- * rejected with an actionable error.
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 registryConfig = await applyNpmRegistryConfig({
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"], { timeout: 3e4 });
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.16";
2244
- const AGENT_SDK_BUNDLE_HASH = "cd4b8e0d91efe578c96e7c746f89b44a3df8fcaebaec2185399168ef8eb4ebca";
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 < IV_LENGTH + AUTH_TAG_LENGTH) throw new Error(`Tarball decryption failed: encrypted data too short (${encrypted.length} bytes, minimum ${IV_LENGTH + AUTH_TAG_LENGTH} bytes)`);
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, IV_LENGTH + AUTH_TAG_LENGTH);
2483
- const ciphertext = encrypted.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
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: buildKiciApi(async (method, params) => {
3348
- const reqId = randomUUID();
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 initSpecs = resolveInitSpecs(job);
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 { rawPayloadFromEvent, resolveInitSpecs };
5205
+ export { createSandboxStepContext, rawPayloadFromEvent };
4310
5206
 
4311
5207
  //# sourceMappingURL=workflow-runner.js.map