@kici-dev/agent 0.1.16 → 0.1.17

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.
@@ -1,21 +1,27 @@
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
27
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
@@ -32,6 +38,122 @@ var __exportAll = (all, no_symbols) => {
32
38
  };
33
39
  import.meta.url;
34
40
  //#endregion
41
+ //#region src/provenance/statement-builder.ts
42
+ /**
43
+ * Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
44
+ * token claims plus the caller-supplied subject. The build context comes
45
+ * entirely from the JWT claims (Platform-minted, unforgeable), so the
46
+ * statement's identity equals the token's identity by construction.
47
+ */
48
+ /** Build a KiCI SLSA v1.0 provenance statement (validates against the P1.1 schema). */
49
+ function buildProvenanceStatement(input) {
50
+ const c = input.tokenClaims;
51
+ return {
52
+ _type: IN_TOTO_STATEMENT_TYPE,
53
+ subject: [{
54
+ name: input.subject.name,
55
+ digest: input.subject.digest
56
+ }],
57
+ predicateType: SLSA_PROVENANCE_PREDICATE_TYPE,
58
+ predicate: {
59
+ buildDefinition: {
60
+ buildType: KICI_WORKFLOW_BUILD_TYPE,
61
+ externalParameters: { workflow: {
62
+ repository: c.repository ?? "",
63
+ ref: c.ref ?? "",
64
+ path: c.workflow_ref ?? ""
65
+ } },
66
+ internalParameters: {
67
+ ...c.sha ? { commit: c.sha } : {},
68
+ runId: c.kici_run_id,
69
+ jobId: c.kici_job_id
70
+ }
71
+ },
72
+ runDetails: {
73
+ builder: {
74
+ id: `${c.iss}/orchestrator/${c.orchestrator_id ?? "unknown"}`,
75
+ version: input.builderVersions
76
+ },
77
+ metadata: {
78
+ invocationId: c.kici_run_id,
79
+ startedOn: input.startedOn,
80
+ finishedOn: input.finishedOn
81
+ }
82
+ }
83
+ }
84
+ };
85
+ }
86
+ //#endregion
87
+ //#region src/provenance/sign.ts
88
+ /**
89
+ * Ephemeral-key DSSE signer for KiCI provenance (Mode A).
90
+ *
91
+ * Generates a fresh in-process ES256 keypair (never persisted), DSSE-signs the
92
+ * PAE of the statement bytes with the private half, and returns the envelope
93
+ * plus the public JWK. The public key travels in the bundle so the verifier can
94
+ * check the signature; the key needs no separate trust root because the bundle's
95
+ * identity JWT (verified against the Platform JWKS) anchors the whole package.
96
+ */
97
+ /** DSSE-sign `statementBytes` with a fresh in-process ephemeral ES256 key. */
98
+ async function signStatementDsse(payloadType, statementBytes) {
99
+ const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true });
100
+ const publicJwk = await exportJWK(publicKey);
101
+ publicJwk.alg = "ES256";
102
+ publicJwk.use = "sig";
103
+ const kid = await calculateJwkThumbprint(publicJwk, "sha256");
104
+ publicJwk.kid = kid;
105
+ const pae = dssePae(payloadType, statementBytes);
106
+ return {
107
+ envelope: buildDsseEnvelope(payloadType, statementBytes, [{
108
+ keyid: kid,
109
+ sig: new Uint8Array(await crypto.subtle.sign({
110
+ name: "ECDSA",
111
+ hash: "SHA-256"
112
+ }, privateKey, pae))
113
+ }]),
114
+ publicJwk
115
+ };
116
+ }
117
+ //#endregion
118
+ //#region src/provenance/attest.ts
119
+ /**
120
+ * Provenance attestation orchestration (Mode A): request the identity token,
121
+ * build the in-toto statement from its claims, DSSE-sign it with an ephemeral
122
+ * key, assemble the KiCI bundle, and persist it.
123
+ */
124
+ async function attestProvenance(deps, input) {
125
+ const audience = input.audience ?? KICI_PROVENANCE_AUDIENCE;
126
+ const { token } = await deps.getIdToken({ audience });
127
+ const claims = decodeJwt(token);
128
+ const now = (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
129
+ const statement = buildProvenanceStatement({
130
+ tokenClaims: claims,
131
+ subject: input.subject,
132
+ builderVersions: deps.builderVersions,
133
+ startedOn: now,
134
+ finishedOn: now
135
+ });
136
+ const { envelope, publicJwk } = await signStatementDsse(IN_TOTO_PAYLOAD_TYPE, new TextEncoder().encode(JSON.stringify(statement)));
137
+ const bundle = {
138
+ mediaType: KICI_PROVENANCE_BUNDLE_MEDIA_TYPE,
139
+ dsseEnvelope: envelope,
140
+ verificationMaterial: {
141
+ publicKey: publicJwk,
142
+ identityToken: token
143
+ }
144
+ };
145
+ const subjectDigest = subjectDigestString(input.subject);
146
+ return {
147
+ storageKey: await deps.persist(bundle, subjectDigest),
148
+ bundle,
149
+ subjectDigest
150
+ };
151
+ }
152
+ /** Pick the primary digest (`sha256` preferred) as the storage-key discriminator. */
153
+ function subjectDigestString(subject) {
154
+ return subject.digest.sha256 ?? Object.values(subject.digest)[0];
155
+ }
156
+ //#endregion
35
157
  //#region src/execution/dep-restore.ts
36
158
  /**
37
159
  * Dependency restoration from cached tarballs.
@@ -370,6 +492,7 @@ var init_download = __esmMin((() => {
370
492
  }));
371
493
  //#endregion
372
494
  //#region src/execution/cache/cache-engine.ts
495
+ init_download();
373
496
  /**
374
497
  * User-facing cache engine (sandbox-side).
375
498
  *
@@ -769,7 +892,8 @@ function applyEnvDelta(delta, options) {
769
892
  }
770
893
  const appliedPaths = [];
771
894
  if (delta.pathPrepends.length > 0) {
772
- for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}:${target.PATH}` : dir;
895
+ const sep = options.pathSeparator ?? (process.platform === "win32" ? ";" : ":");
896
+ for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}${sep}${target.PATH}` : dir;
773
897
  appliedPaths.push(...delta.pathPrepends);
774
898
  }
775
899
  return {
@@ -1514,6 +1638,256 @@ async function runOneInit(spec, index, stepIndex, stepType, opts) {
1514
1638
  }
1515
1639
  }
1516
1640
  //#endregion
1641
+ //#region src/execution/env-init/presets/directives.ts
1642
+ function isPresetString(item) {
1643
+ return item === "mise";
1644
+ }
1645
+ function isMiseObject(item) {
1646
+ return typeof item === "object" && item !== null && "mise" in item;
1647
+ }
1648
+ function normalizeOne(item) {
1649
+ if (isPresetString(item)) return {
1650
+ kind: "preset",
1651
+ name: "mise",
1652
+ config: {}
1653
+ };
1654
+ if (isMiseObject(item)) return {
1655
+ kind: "preset",
1656
+ name: "mise",
1657
+ config: item.mise
1658
+ };
1659
+ return {
1660
+ kind: "generic",
1661
+ config: item
1662
+ };
1663
+ }
1664
+ /**
1665
+ * Normalize `Job.init` to an ordered list of directives, without touching the
1666
+ * filesystem. `false`/`undefined` -> []; `'auto'` -> one auto directive;
1667
+ * presets/generic configs -> their directive; arrays map element-wise.
1668
+ * `'auto'` is a scalar only — finding it inside an array throws.
1669
+ */
1670
+ function normalizeInitItems(job) {
1671
+ const init = job?.init;
1672
+ if (init === void 0 || init === false) return [];
1673
+ if (init === "auto") return [{ kind: "auto" }];
1674
+ if (Array.isArray(init)) return init.map((item) => {
1675
+ if (item === "auto") throw new Error("init: 'auto' cannot be combined in an array — use it as the sole value");
1676
+ return normalizeOne(item);
1677
+ });
1678
+ return [normalizeOne(init)];
1679
+ }
1680
+ //#endregion
1681
+ //#region src/execution/env-init/presets/mise/cache-key.ts
1682
+ /** mise config files, in the fixed order they feed the content hash. */
1683
+ const MISE_CONFIG_FILES = [
1684
+ "mise.toml",
1685
+ ".mise.toml",
1686
+ ".tool-versions"
1687
+ ];
1688
+ /**
1689
+ * Derive the default mise cache key from the committed mise config under
1690
+ * `cloneRoot`. Concatenates whichever of {@link MISE_CONFIG_FILES} exist (in
1691
+ * fixed order) and hashes them. Returns `mise-noconfig` when none exist.
1692
+ */
1693
+ async function miseCacheKey(cloneRoot) {
1694
+ const hash = createHash("sha256");
1695
+ let found = false;
1696
+ for (const name of MISE_CONFIG_FILES) try {
1697
+ const buf = await readFile(join(cloneRoot, name));
1698
+ hash.update(name);
1699
+ hash.update(buf);
1700
+ found = true;
1701
+ } catch {}
1702
+ if (!found) return "mise-noconfig";
1703
+ return `mise-${hash.digest("hex").slice(0, 16)}`;
1704
+ }
1705
+ //#endregion
1706
+ //#region src/execution/env-init/presets/mise/templates.ts
1707
+ const BASH_RUN = `set -euo pipefail
1708
+ command -v mise >/dev/null || curl -fsSL https://mise.run | sh
1709
+ export PATH="$HOME/.local/bin:$PATH"
1710
+ # Trust the committed config at the clone root (CWD): mise refuses to load an
1711
+ # untrusted config, and the author committing it to their repo is the trust signal.
1712
+ mise trust
1713
+ mise install
1714
+ mise env -s bash | sed -n 's/^export //p' | sed '/^PATH=/d' \\
1715
+ | sed -E 's/^([A-Za-z_][A-Za-z0-9_]*)="(.*)"$/\\1=\\2/' >> "$KICI_ENV"
1716
+ echo "$HOME/.local/share/mise/shims" >> "$KICI_PATH"`;
1717
+ const PWSH_RUN = `$ErrorActionPreference = 'Stop'
1718
+ # mise writes informational output (\`mise trusted …\`, install progress) to
1719
+ # stderr even on success. Under \`$ErrorActionPreference = 'Stop'\` PowerShell
1720
+ # turns any native-command stderr line into a terminating error, so a
1721
+ # successful \`mise trust\` would abort the step. Run each mise invocation with
1722
+ # the preference relaxed and gate on the real exit code via \`$LASTEXITCODE\`.
1723
+ function Invoke-Mise {
1724
+ param([Parameter(ValueFromRemainingArguments = $true)][string[]] $MiseArgs)
1725
+ $prev = $ErrorActionPreference
1726
+ $ErrorActionPreference = 'Continue'
1727
+ try {
1728
+ $output = & mise @MiseArgs 2>&1
1729
+ $code = $LASTEXITCODE
1730
+ } finally {
1731
+ $ErrorActionPreference = $prev
1732
+ }
1733
+ if ($code -ne 0) {
1734
+ throw "mise $($MiseArgs -join ' ') failed (exit $code): $($output -join ' | ')"
1735
+ }
1736
+ return $output
1737
+ }
1738
+ if (-not (Get-Command mise -ErrorAction SilentlyContinue)) {
1739
+ # The standalone Windows zip extracts to mise/bin/mise.exe, so prepend the
1740
+ # nested bin dir (not the extraction root) to PATH.
1741
+ $dest = Join-Path $env:USERPROFILE '.local\\mise'
1742
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
1743
+ $zip = Join-Path $env:TEMP 'mise.zip'
1744
+ Invoke-WebRequest -Uri '<ASSET_URL>' -OutFile $zip
1745
+ Expand-Archive -Path $zip -DestinationPath $dest -Force
1746
+ $env:PATH = "$dest\\mise\\bin;$env:PATH"
1747
+ }
1748
+ # Trust the committed config at the clone root (CWD) — mise refuses to load an
1749
+ # untrusted config; the author committing it to their repo is the trust signal.
1750
+ Invoke-Mise trust | Out-Null
1751
+ Invoke-Mise install | Out-Null
1752
+ Invoke-Mise env -s pwsh | ForEach-Object {
1753
+ if ($_ -match '^\\$env:([^=]+) = ''(.*)''$' -and $Matches[1] -ne 'PATH') { "$($Matches[1])=$($Matches[2])" }
1754
+ } | Add-Content -Path $env:KICI_ENV
1755
+ # Add the real tool install dirs (not the shims dir): the standalone mise lives
1756
+ # in a temp dir that is gone by step time, so the shim wrappers (which re-invoke
1757
+ # mise) cannot resolve it. bin-paths points straight at the installed binaries.
1758
+ Invoke-Mise bin-paths | Add-Content -Path $env:KICI_PATH`;
1759
+ /**
1760
+ * Pick the mise template for a host platform (Node `process.platform` value).
1761
+ * The Windows `run` carries an `<ASSET_URL>` placeholder the expander replaces
1762
+ * with the resolved GitHub-release zip URL.
1763
+ */
1764
+ function selectMiseTemplate(platform) {
1765
+ if (platform === "win32") return {
1766
+ run: PWSH_RUN,
1767
+ shell: "pwsh",
1768
+ cachePaths: ["~/AppData/Local/mise"]
1769
+ };
1770
+ if (platform === "linux" || platform === "darwin") return {
1771
+ run: BASH_RUN,
1772
+ shell: "bash",
1773
+ cachePaths: ["~/.local/share/mise"]
1774
+ };
1775
+ throw new Error(`unsupported platform for mise preset: ${platform}`);
1776
+ }
1777
+ //#endregion
1778
+ //#region src/execution/env-init/presets/mise/windows-install.ts
1779
+ /** Map a Windows `PROCESSOR_ARCHITECTURE` value to mise's asset arch slug. */
1780
+ function miseWindowsArch(processorArch) {
1781
+ return processorArch?.toUpperCase() === "ARM64" ? "arm64" : "x64";
1782
+ }
1783
+ const LATEST_RELEASE_URL = "https://api.github.com/repos/jdx/mise/releases/latest";
1784
+ /**
1785
+ * Resolve the download URL of the latest mise standalone Windows zip for `arch`.
1786
+ * `fetchJson` is injected (defaults to a real fetch) so the resolution is
1787
+ * unit-testable without network.
1788
+ */
1789
+ async function resolveLatestMiseWindowsAsset(arch, fetchJson = defaultFetchJson) {
1790
+ const release = await fetchJson(LATEST_RELEASE_URL);
1791
+ const suffix = `-windows-${arch}.zip`;
1792
+ const asset = release.assets.find((a) => a.name.endsWith(suffix));
1793
+ if (!asset) throw new Error(`no mise windows ${arch} asset in latest release`);
1794
+ return asset.browser_download_url;
1795
+ }
1796
+ async function defaultFetchJson(url) {
1797
+ const res = await fetch(url, { headers: { "user-agent": "kici-agent" } });
1798
+ if (!res.ok) throw new Error(`mise release lookup failed: ${res.status}`);
1799
+ return await res.json();
1800
+ }
1801
+ //#endregion
1802
+ //#region src/execution/env-init/presets/mise/expander.ts
1803
+ async function buildRun(args, template) {
1804
+ if ((args.platform ?? process.platform) !== "win32") return template.run;
1805
+ const arch = miseWindowsArch(process.env.PROCESSOR_ARCHITECTURE);
1806
+ const url = await (args.resolveWindowsAsset ?? ((a) => resolveLatestMiseWindowsAsset(a)))(arch);
1807
+ return template.run.replace("<ASSET_URL>", url);
1808
+ }
1809
+ async function defaultCache(cloneRoot, paths) {
1810
+ return {
1811
+ key: await miseCacheKey(cloneRoot),
1812
+ paths,
1813
+ restoreKeys: ["mise-"]
1814
+ };
1815
+ }
1816
+ //#endregion
1817
+ //#region src/execution/env-init/presets/registry.ts
1818
+ /**
1819
+ * The set of typed presets. Nix is added here (one row) once its provider lands.
1820
+ */
1821
+ const PRESET_REGISTRY = { mise: { async expand(args) {
1822
+ const template = selectMiseTemplate(args.platform ?? process.platform);
1823
+ const run = await buildRun(args, template);
1824
+ const cache = args.config.cache === false ? void 0 : args.config.cache ?? await defaultCache(args.cloneRoot, template.cachePaths);
1825
+ const cfg = {
1826
+ run,
1827
+ shell: args.config.shell ?? template.shell,
1828
+ timeout: args.config.timeout ?? 6e5
1829
+ };
1830
+ if (cache) cfg.cache = cache;
1831
+ if (args.config.env) cfg.env = args.config.env;
1832
+ return cfg;
1833
+ } } };
1834
+ /**
1835
+ * Ordered auto-detect table: `init: 'auto'` tries each row against the clone
1836
+ * root and accumulates matches in this order. (nix row added with its provider.)
1837
+ */
1838
+ const AUTO_DETECT_TABLE = [{
1839
+ markers: [
1840
+ "mise.toml",
1841
+ ".mise.toml",
1842
+ ".tool-versions"
1843
+ ],
1844
+ preset: "mise"
1845
+ }];
1846
+ //#endregion
1847
+ //#region src/execution/env-init/presets/expand.ts
1848
+ async function fileExists$2(p) {
1849
+ try {
1850
+ await access(p);
1851
+ return true;
1852
+ } catch {
1853
+ return false;
1854
+ }
1855
+ }
1856
+ /** Scan the clone root for marker files and return matched presets in table order. */
1857
+ async function autoDetect(cloneRoot) {
1858
+ const matched = [];
1859
+ for (const row of AUTO_DETECT_TABLE) for (const marker of row.markers) if (await fileExists$2(join(cloneRoot, marker))) {
1860
+ matched.push(row.preset);
1861
+ break;
1862
+ }
1863
+ return matched;
1864
+ }
1865
+ async function expandPreset(name, config, opts) {
1866
+ return PRESET_REGISTRY[name].expand({
1867
+ cloneRoot: opts.cloneRoot,
1868
+ config,
1869
+ ...opts.platform ? { platform: opts.platform } : {}
1870
+ });
1871
+ }
1872
+ /**
1873
+ * Expand normalized directives into concrete generic init configs, reading the
1874
+ * clone root for preset cache keys and `'auto'` marker detection.
1875
+ */
1876
+ async function expandInitDirectives(directives, opts) {
1877
+ const out = [];
1878
+ for (const d of directives) if (d.kind === "generic") out.push(d.config);
1879
+ else if (d.kind === "preset") out.push(await expandPreset(d.name, d.config, opts));
1880
+ else {
1881
+ const presets = await autoDetect(opts.cloneRoot);
1882
+ if (presets.length === 0) {
1883
+ opts.log?.("[kici] init: auto — no toolchain detected (no mise.toml / .tool-versions)");
1884
+ continue;
1885
+ }
1886
+ for (const name of presets) out.push(await expandPreset(name, {}, opts));
1887
+ }
1888
+ return out;
1889
+ }
1890
+ //#endregion
1517
1891
  //#region src/execution/sandbox/job-deadline.ts
1518
1892
  /**
1519
1893
  * Arm a job-level wall-clock deadline. When `timeoutMs` is set and elapses
@@ -1962,6 +2336,17 @@ function isInsideRepo(repoRoot, target) {
1962
2336
  */
1963
2337
  async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
1964
2338
  if (packageManager === PackageManager.Npm) return [...deps];
2339
+ if (packageManager === PackageManager.Yarn) {
2340
+ const unresolvable = [];
2341
+ for (const dep of deps) {
2342
+ if (dep.protocol === "workspace:" || dep.protocol === "portal:") {
2343
+ unresolvable.push(dep);
2344
+ continue;
2345
+ }
2346
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
2347
+ }
2348
+ return unresolvable;
2349
+ }
1965
2350
  const hasWorkspaceFile = await fileExists$1(join(repoRoot, "pnpm-workspace.yaml"));
1966
2351
  const unresolvable = [];
1967
2352
  for (const dep of deps) {
@@ -1977,6 +2362,7 @@ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
1977
2362
  function formatUnresolvableDepError(offenders, packageManager) {
1978
2363
  const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
1979
2364
  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.`;
2365
+ 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 is planned.)`;
1980
2366
  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
2367
  }
1982
2368
  /**
@@ -1995,6 +2381,101 @@ async function assertResolvableDeps(args) {
1995
2381
  throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
1996
2382
  }
1997
2383
  //#endregion
2384
+ //#region src/execution/workspace-siblings.ts
2385
+ /**
2386
+ * In-repo workspace-sibling discovery for the agent's dependency handling.
2387
+ *
2388
+ * A pnpm or yarn-classic workspace lays out a `.kici/` member's `workspace:`
2389
+ * (pnpm) or version-range (yarn) siblings as symlinks pointing at package
2390
+ * directories that live inside the clone but outside `.kici/` and outside the
2391
+ * `node_modules` store. The dep-cache packer must travel those sibling dirs with
2392
+ * the closure (their symlinks would dangle otherwise), and the yarn install path
2393
+ * must build them (the install links a sibling but does not build it).
2394
+ *
2395
+ * `collectInRepoSiblings` walks a starting `node_modules` (and transitively each
2396
+ * discovered sibling's `node_modules`), returning each in-repo sibling directory
2397
+ * once, repo-root-relative, in breadth-first discovery order. The starting
2398
+ * `node_modules` is a parameter so it serves pnpm + yarn-standalone (seeded at
2399
+ * `.kici/node_modules`) and yarn-workspace-member (seeded at the hoisted root
2400
+ * `node_modules`).
2401
+ */
2402
+ /**
2403
+ * The directory yarn lays `.kici`'s dependencies into. A standalone `.kici`
2404
+ * (own lockfile, no parent workspace) gets `.kici/node_modules`; a workspace
2405
+ * member hoists everything to the repo-root `node_modules`, leaving no
2406
+ * `.kici/node_modules`.
2407
+ */
2408
+ function resolveYarnNodeModulesRoot(repoRoot, kiciDir) {
2409
+ const kiciNm = join(kiciDir, "node_modules");
2410
+ return existsSync(kiciNm) ? kiciNm : join(repoRoot, "node_modules");
2411
+ }
2412
+ /**
2413
+ * Walk `seedNodeModules` (and transitively each in-repo sibling's
2414
+ * `node_modules`) collecting the repo-root-relative directories of workspace
2415
+ * siblings — package dirs that live inside the clone but outside `.kici/` and
2416
+ * outside the repo-root `node_modules/` store. Returns each dir once, in
2417
+ * discovery (BFS) order.
2418
+ */
2419
+ async function collectInRepoSiblings(workDir, kiciDir, seedNodeModules = join(kiciDir, "node_modules")) {
2420
+ const repoRoot = resolve(workDir);
2421
+ const kiciResolved = resolve(kiciDir);
2422
+ const rootNodeModules = resolve(join(workDir, "node_modules"));
2423
+ const found = /* @__PURE__ */ new Set();
2424
+ const visited = /* @__PURE__ */ new Set();
2425
+ const queue = [seedNodeModules];
2426
+ while (queue.length > 0) {
2427
+ const nmDir = queue.shift();
2428
+ const real = await realpath(nmDir).catch(() => null);
2429
+ if (!real || visited.has(real)) continue;
2430
+ visited.add(real);
2431
+ for (const target of await resolveNodeModulesLinks(nmDir)) {
2432
+ if (!isInside(repoRoot, target)) continue;
2433
+ if (isInside(kiciResolved, target) || isInside(rootNodeModules, target)) continue;
2434
+ const rel = relative(workDir, target);
2435
+ if (!found.has(rel)) {
2436
+ found.add(rel);
2437
+ queue.push(join(target, "node_modules"));
2438
+ }
2439
+ }
2440
+ }
2441
+ return [...found];
2442
+ }
2443
+ /** Resolve every package symlink target under a `node_modules` dir (descending one level into `@scope` dirs). */
2444
+ async function resolveNodeModulesLinks(nmDir) {
2445
+ const targets = [];
2446
+ for (const entry of await readdir(nmDir).catch(() => [])) {
2447
+ if (entry.startsWith(".")) continue;
2448
+ const entryPath = join(nmDir, entry);
2449
+ if (entry.startsWith("@")) {
2450
+ for (const scoped of await readdir(entryPath).catch(() => [])) {
2451
+ const target = await resolveIfSymlink(join(entryPath, scoped));
2452
+ if (target) targets.push(target);
2453
+ }
2454
+ continue;
2455
+ }
2456
+ const target = await resolveIfSymlink(entryPath);
2457
+ if (target) targets.push(target);
2458
+ }
2459
+ return targets;
2460
+ }
2461
+ /** Return the real path of `p` if it is a symlink, else null. */
2462
+ async function resolveIfSymlink(p) {
2463
+ try {
2464
+ if (!(await lstat(p)).isSymbolicLink()) return null;
2465
+ return await realpath(p);
2466
+ } catch {
2467
+ return null;
2468
+ }
2469
+ }
2470
+ /** Whether `target` is `root` itself or a path inside it. */
2471
+ function isInside(root, target) {
2472
+ const rel = relative(root, target);
2473
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsoluteRel(rel);
2474
+ }
2475
+ function isAbsoluteRel(rel) {
2476
+ return rel.length > 1 && rel[1] === ":";
2477
+ }
2478
+ //#endregion
1998
2479
  //#region src/execution/dep-installer.ts
1999
2480
  /**
2000
2481
  * Inline dependency installation for graceful degradation.
@@ -2002,12 +2483,14 @@ async function assertResolvableDeps(args) {
2002
2483
  * When the dep cache is unavailable or a download fails, the agent installs
2003
2484
  * `.kici/` dependencies directly with the repository's package manager.
2004
2485
  *
2005
- * The package manager is detected from the cloned repo (npm / pnpm); the
2486
+ * The package manager is detected from the cloned repo (npm / pnpm / yarn); the
2006
2487
  * presence of `.kici/package.json` signals that deps should be installed. npm
2007
2488
  * is the default and ships with every Node.js install; pnpm is used when the
2008
2489
  * 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.
2490
+ * `workspace:` siblings. yarn classic (v1) is supported for registry
2491
+ * dependencies and version-range workspace siblings (which it links but does
2492
+ * not build, so the agent builds the in-repo closure after install). yarn
2493
+ * berry (v2+) is not yet supported.
2011
2494
  *
2012
2495
  * Security: the install runs with an isolated per-invocation cache/store
2013
2496
  * directory to prevent cache poisoning across build jobs — a malicious
@@ -2056,7 +2539,6 @@ async function installDeps(kiciDir, opts = {}) {
2056
2539
  dir: kiciDir
2057
2540
  });
2058
2541
  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.");
2060
2542
  await assertResolvableDeps({
2061
2543
  kiciDir,
2062
2544
  repoRoot,
@@ -2076,6 +2558,11 @@ async function installDeps(kiciDir, opts = {}) {
2076
2558
  hasPrivateRegistry,
2077
2559
  registryConfig
2078
2560
  });
2561
+ else if (packageManager === PackageManager.Yarn) await runYarnInstall({
2562
+ kiciDir,
2563
+ hasPrivateRegistry,
2564
+ registryConfig
2565
+ });
2079
2566
  else await runNpmInstall({
2080
2567
  kiciDir,
2081
2568
  hasPrivateRegistry,
@@ -2090,6 +2577,7 @@ async function installDeps(kiciDir, opts = {}) {
2090
2577
  await registryConfig.cleanup();
2091
2578
  }
2092
2579
  if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
2580
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir);
2093
2581
  const durationMs = Date.now() - startTime;
2094
2582
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
2095
2583
  logger$2.info("Deps installed inline", {
@@ -2177,6 +2665,101 @@ async function runPnpmInstall(args) {
2177
2665
  }).catch(() => {});
2178
2666
  }
2179
2667
  }
2668
+ /** Pure: argv for `yarn install` with an isolated cache folder. */
2669
+ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
2670
+ const a = [
2671
+ "install",
2672
+ "--cache-folder",
2673
+ cacheDir,
2674
+ "--non-interactive",
2675
+ "--no-progress"
2676
+ ];
2677
+ if (hasPrivateRegistry) a.push("--ignore-scripts");
2678
+ return a;
2679
+ }
2680
+ /**
2681
+ * Run `yarn install` from `.kici/` with an isolated cache folder. yarn classic
2682
+ * reads the synthesized `.kici/.npmrc` (registry + `${VAR}` token expansion) for
2683
+ * private-registry auth. A workspace member hoists deps to the repo-root
2684
+ * node_modules; a standalone `.kici` gets `.kici/node_modules`. Not
2685
+ * `--frozen-lockfile` (resolved URLs in the lockfile may point at a different
2686
+ * registry than the synthesized `.npmrc`, e.g. localhost tunnel vs direct IP).
2687
+ */
2688
+ async function runYarnInstall(args) {
2689
+ await assertYarnAvailable();
2690
+ const { nodeDir } = resolveNpm();
2691
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-yarn-cache-"));
2692
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
2693
+ const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
2694
+ try {
2695
+ process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
2696
+ await execFileAsync("yarn", argv, {
2697
+ cwd: args.kiciDir,
2698
+ env,
2699
+ timeout: INSTALL_TIMEOUT_MS,
2700
+ maxBuffer: INSTALL_MAX_BUFFER
2701
+ });
2702
+ } finally {
2703
+ await rm(cacheDir, {
2704
+ recursive: true,
2705
+ force: true
2706
+ }).catch(() => {});
2707
+ }
2708
+ }
2709
+ /** Throw an actionable error when the repo needs yarn but it is not installed. */
2710
+ async function assertYarnAvailable() {
2711
+ try {
2712
+ await execFileAsync("yarn", ["--version"], {
2713
+ timeout: 3e4,
2714
+ cwd: tmpdir()
2715
+ });
2716
+ } catch (e) {
2717
+ 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)})`);
2718
+ }
2719
+ }
2720
+ /**
2721
+ * Build the in-repo workspace siblings `.kici` depends on (yarn links them on
2722
+ * install but does not build them). Walks siblings from the resolved
2723
+ * node_modules root and runs each sibling's `build` script in leaf-first
2724
+ * (reverse-discovery) order with a clean env (no synthesized registry tokens).
2725
+ * Deep cross-sibling build chains may build out of strict topological order —
2726
+ * real `.kici` closures are shallow.
2727
+ */
2728
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir) {
2729
+ const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
2730
+ if (siblings.length === 0) return;
2731
+ const { nodeDir } = resolveNpm();
2732
+ const env = envWithNodeOnPath({}, nodeDir);
2733
+ for (const rel of [...siblings].reverse()) {
2734
+ const sibDir = join(repoRoot, rel);
2735
+ if (!await siblingHasBuildScript(sibDir)) continue;
2736
+ process.stderr.write(`[dep-installer:trace] building yarn sibling: yarn --cwd ${sibDir} run build\n`);
2737
+ try {
2738
+ await execFileAsync("yarn", [
2739
+ "--cwd",
2740
+ sibDir,
2741
+ "run",
2742
+ "build"
2743
+ ], {
2744
+ cwd: repoRoot,
2745
+ env,
2746
+ timeout: INSTALL_TIMEOUT_MS,
2747
+ maxBuffer: INSTALL_MAX_BUFFER
2748
+ });
2749
+ } catch (e) {
2750
+ logSubprocessStreams(e, []);
2751
+ throw new Error(`Failed to build .kici yarn workspace sibling ${rel}: ${describeExecError(e)}`);
2752
+ }
2753
+ }
2754
+ }
2755
+ /** Whether a sibling package.json declares a `build` script. */
2756
+ async function siblingHasBuildScript(sibDir) {
2757
+ try {
2758
+ return typeof JSON.parse(await readFile(join(sibDir, "package.json"), "utf-8")).scripts?.build === "string";
2759
+ } catch {
2760
+ return false;
2761
+ }
2762
+ }
2180
2763
  /**
2181
2764
  * Build the in-repo dependency closure of the `.kici/` package so a
2182
2765
  * `workspace:` sibling's build output exists before the workflow that imports
@@ -2220,7 +2803,10 @@ function describeExecError(e) {
2220
2803
  /** Throw an actionable error when the repo needs pnpm but it is not installed. */
2221
2804
  async function assertPnpmAvailable() {
2222
2805
  try {
2223
- await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
2806
+ await execFileAsync("pnpm", ["--version"], {
2807
+ timeout: 3e4,
2808
+ cwd: tmpdir()
2809
+ });
2224
2810
  } catch (e) {
2225
2811
  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
2812
  }
@@ -2240,8 +2826,8 @@ function logSubprocessStreams(e, tokens) {
2240
2826
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
2241
2827
  * Node's normal ESM lookup against `.kici/node_modules/`.
2242
2828
  */
2243
- const AGENT_SDK_VERSION = "0.1.16";
2244
- const AGENT_SDK_BUNDLE_HASH = "cd4b8e0d91efe578c96e7c746f89b44a3df8fcaebaec2185399168ef8eb4ebca";
2829
+ const AGENT_SDK_VERSION = "0.1.17";
2830
+ const AGENT_SDK_BUNDLE_HASH = "df47ed5db86eaaa2de8394c0db08335f368e8d620a898cc409765f4545eb3972";
2245
2831
  /**
2246
2832
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
2247
2833
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -2424,7 +3010,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
2424
3010
  * runs without a preceding build (cache infrastructure unavailable, or a
2425
3011
  * build job that failed but left dynamic dispatch in flight).
2426
3012
  */
2427
- init_download();
2428
3013
  init_dep_restore();
2429
3014
  const logger$1 = createLogger({ prefix: "source-restore" });
2430
3015
  async function extractSourceTarball(data, targetDir) {
@@ -2470,18 +3055,17 @@ async function restoreSource(workDir, sourceTarUrl) {
2470
3055
  init_download();
2471
3056
  const logger = createLogger({ prefix: "overlay-applier" });
2472
3057
  const IV_LENGTH = 12;
2473
- const AUTH_TAG_LENGTH = 16;
2474
3058
  /**
2475
3059
  * Decrypt an encrypted buffer using AES-256-GCM.
2476
3060
  *
2477
3061
  * Wire format: [12-byte IV][16-byte auth tag][ciphertext]
2478
3062
  */
2479
3063
  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)`);
3064
+ if (encrypted.length < 28) throw new Error(`Tarball decryption failed: encrypted data too short (${encrypted.length} bytes, minimum 28 bytes)`);
2481
3065
  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);
3066
+ const authTag = encrypted.subarray(IV_LENGTH, 28);
3067
+ const ciphertext = encrypted.subarray(28);
3068
+ const decipher = crypto$1.createDecipheriv("aes-256-gcm", aesKey, iv);
2485
3069
  decipher.setAuthTag(authTag);
2486
3070
  try {
2487
3071
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
@@ -2601,7 +3185,9 @@ async function applyOverlay(config) {
2601
3185
  * This file is compiled alongside the agent by rolldown (existing build), but
2602
3186
  * runs as a SEPARATE process spawned by the sandbox backend.
2603
3187
  */
3188
+ init_download();
2604
3189
  init_dep_restore();
3190
+ const AGENT_VERSION = "0.1.17";
2605
3191
  process.on("uncaughtException", (err) => {
2606
3192
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
2607
3193
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -2911,6 +3497,33 @@ function waitForApiResponse(requestId) {
2911
3497
  const pendingCacheResponses = /* @__PURE__ */ new Map();
2912
3498
  /** Default timeout for a cache request relay (matches the upload-URL request budget). */
2913
3499
  const CACHE_RESPONSE_TIMEOUT_MS = 3e4;
3500
+ /**
3501
+ * Pending promises for provenance.response messages from the agent.
3502
+ * Key: requestId (correlates provenance.request -> provenance.response).
3503
+ */
3504
+ const pendingProvenanceResponses = /* @__PURE__ */ new Map();
3505
+ /** Wait for a provenance.response from the agent with the given requestId. */
3506
+ function waitForProvenanceResponse(requestId) {
3507
+ return new Promise((resolve, reject) => {
3508
+ const timer = setTimeout(() => {
3509
+ pendingProvenanceResponses.delete(requestId);
3510
+ reject(/* @__PURE__ */ new Error(`Provenance request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
3511
+ }, CACHE_RESPONSE_TIMEOUT_MS);
3512
+ pendingProvenanceResponses.set(requestId, {
3513
+ resolve: (response) => {
3514
+ clearTimeout(timer);
3515
+ pendingProvenanceResponses.delete(requestId);
3516
+ resolve(response);
3517
+ },
3518
+ reject: (err) => {
3519
+ clearTimeout(timer);
3520
+ pendingProvenanceResponses.delete(requestId);
3521
+ reject(err);
3522
+ },
3523
+ timer
3524
+ });
3525
+ });
3526
+ }
2914
3527
  /** Wait for a cache.response from the agent with the given requestId. */
2915
3528
  function waitForCacheResponse(requestId) {
2916
3529
  return new Promise((resolve, reject) => {
@@ -3051,6 +3664,64 @@ function buildCacheTransport() {
3051
3664
  }
3052
3665
  };
3053
3666
  }
3667
+ /** Send a `provenance.request` IPC and await the matching `provenance.response`. */
3668
+ async function relayProvenanceIpc(request) {
3669
+ const requestId = randomUUID();
3670
+ sendMessage({
3671
+ type: "provenance.request",
3672
+ requestId,
3673
+ ...request
3674
+ });
3675
+ const response = await waitForProvenanceResponse(requestId);
3676
+ if (response.error) throw new Error(`Provenance relay failed: ${response.error}`);
3677
+ return response;
3678
+ }
3679
+ /**
3680
+ * Build the `ctx.attestProvenance` step helper. Resolves a `path` subject to a
3681
+ * SHA-256 digest, threads the identity token via the supplied OIDC getter, and
3682
+ * persists the bundle over the IPC -> WS provenance-upload relay.
3683
+ */
3684
+ function buildAttestProvenanceFn(request, workDir, getIdToken) {
3685
+ return async (opts) => {
3686
+ const subject = provenanceSubjectIsPath(opts.subject) ? {
3687
+ name: opts.subject.name,
3688
+ digest: { sha256: await sha256File$1(join(workDir, opts.subject.path)) }
3689
+ } : {
3690
+ name: opts.subject.name,
3691
+ digest: opts.subject.digest
3692
+ };
3693
+ const result = await attestProvenance({
3694
+ getIdToken,
3695
+ builderVersions: {
3696
+ "kici-agent": AGENT_VERSION,
3697
+ "kici-orchestrator": "unknown"
3698
+ },
3699
+ persist: async (bundle, subjectDigest) => {
3700
+ const urlResponse = await relayProvenanceIpc({
3701
+ op: "requestUploadUrl",
3702
+ subjectDigest
3703
+ });
3704
+ if (!urlResponse.uploadUrl) throw new Error("Orchestrator returned no provenance upload URL");
3705
+ await uploadToPresignedUrl(urlResponse.uploadUrl, Buffer.from(JSON.stringify(bundle)));
3706
+ await relayProvenanceIpc({
3707
+ op: "complete",
3708
+ subjectDigest,
3709
+ subjectName: subject.name,
3710
+ mediaType: bundle.mediaType
3711
+ });
3712
+ return `provenance/${request.runId}/${request.jobId}/${subjectDigest}.kici.json`;
3713
+ }
3714
+ }, {
3715
+ subject,
3716
+ ...opts.audience !== void 0 && { audience: opts.audience }
3717
+ });
3718
+ return {
3719
+ storageKey: result.storageKey,
3720
+ subjectDigest: result.subjectDigest,
3721
+ bundleMediaType: result.bundle.mediaType
3722
+ };
3723
+ };
3724
+ }
3054
3725
  /**
3055
3726
  * Build the declarative-cache phase dependencies and run the job-level cache
3056
3727
  * restore (Phase 9b).
@@ -3108,6 +3779,9 @@ function dispatchAgentMessage(msg) {
3108
3779
  } else if (msg.type === "cache.response") {
3109
3780
  const pending = pendingCacheResponses.get(msg.requestId);
3110
3781
  if (pending) pending.resolve(msg);
3782
+ } else if (msg.type === "provenance.response") {
3783
+ const pending = pendingProvenanceResponses.get(msg.requestId);
3784
+ if (pending) pending.resolve(msg);
3111
3785
  } else if (msg.type === "approval.resolved") {
3112
3786
  const pending = pendingApprovalResolutions.get(msg.requestId);
3113
3787
  if (pending) pending.resolve(msg);
@@ -3289,10 +3963,22 @@ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
3289
3963
  * NOT serialized across the process boundary. This means zx $ runs natively
3290
3964
  * inside this process with full shell access.
3291
3965
  */
3292
- function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
3966
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker) {
3293
3967
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
3294
3968
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
3295
3969
  const rawPayload = rawPayloadFromEvent(request.event);
3970
+ const kici = buildKiciApi(async (method, params) => {
3971
+ const reqId = randomUUID();
3972
+ sendMessage({
3973
+ type: "agent.api.request",
3974
+ requestId: reqId,
3975
+ method,
3976
+ params: params ?? {}
3977
+ });
3978
+ const result = await waitForApiResponse(reqId);
3979
+ if (method === OIDC_TOKEN_REQUEST_METHOD && result && typeof result.token === "string") masker.registerSecrets({ __oidc_token__: result.token });
3980
+ return result;
3981
+ }, { jobId: request.jobId });
3296
3982
  return {
3297
3983
  $: step$,
3298
3984
  log,
@@ -3344,18 +4030,11 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
3344
4030
  setSecretOutput: (key, value) => {
3345
4031
  secretOutputs.set(key, value);
3346
4032
  },
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
- }),
4033
+ kici,
4034
+ attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
3357
4035
  ...rawPayload && { rawPayload },
3358
- ...request.provider && { provider: request.provider }
4036
+ ...request.provider && { provider: request.provider },
4037
+ ...request.matrixValues && { matrix: request.matrixValues }
3359
4038
  };
3360
4039
  }
3361
4040
  /** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
@@ -3498,6 +4177,31 @@ async function applyOverlayIfRequested(request, workflowDir) {
3498
4177
  trace(`overlay applied: ${overlayResult.filesApplied} files, ${overlayResult.filesDeleted} deletions`);
3499
4178
  }
3500
4179
  /**
4180
+ * Phase 1c — Make git usable in a full-repo overlay workspace.
4181
+ *
4182
+ * `kici run remote` uploads the developer's working tree (including `.git`) as
4183
+ * a self-contained overlay; no clone happens, so the extracted `.git` directory
4184
+ * is owned by whatever UID wrote the tarball. Under rootless podman that UID may
4185
+ * not match the container UID, which trips git's "dubious ownership" /
4186
+ * `safe.directory` check and makes every step `git` command fail.
4187
+ *
4188
+ * Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
4189
+ * `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
4190
+ * on `process.env` here (before the step loop) means every step subprocess —
4191
+ * each zx `$` snapshots `process.env` at context creation — inherits it, so git
4192
+ * works in steps exactly as it does locally. We also register the dep-restore
4193
+ * scratch-dir exclude now that a real `.git` exists in the workspace.
4194
+ */
4195
+ async function makeOverlayGitUsable(request, workspaceDir) {
4196
+ if (!request.fullRepo) return;
4197
+ if (!existsSync(join(workspaceDir, ".git"))) return;
4198
+ const cfgPath = join(await fsPromises.mkdtemp(join(tmpdir(), "kici-gitcfg-")), "config");
4199
+ await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
4200
+ process.env.GIT_CONFIG_GLOBAL = cfgPath;
4201
+ trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
4202
+ await excludeScratchFromGit(workspaceDir);
4203
+ }
4204
+ /**
3501
4205
  * Phase 2 — Restore deps from cache (with hash-mismatch hard-fail) OR fall
3502
4206
  * back to inline install. Skipped when `.kici/package.json` doesn't exist.
3503
4207
  * For global workflows deps come from the workflow repo (where `.kici/` lives).
@@ -3980,15 +4684,6 @@ function collectJobHooks(job) {
3980
4684
  return jobHooks;
3981
4685
  }
3982
4686
  /**
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
4687
  * Base stepIndex for the `init:<n>` pseudo-steps. The step loop reserves the
3993
4688
  * range starting at `steps.length` for hook pseudo-steps (`beforeStep` =
3994
4689
  * `steps.length + i*2`, `afterStep` = `steps.length + i*2 + 1`, and job-level
@@ -4064,7 +4759,16 @@ function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
4064
4759
  */
4065
4760
  async function runInitPhaseOrFailJob(args) {
4066
4761
  const { job, stepCwd, envFiles, operatorSecretKeys, maskedSend } = args;
4067
- const initSpecs = resolveInitSpecs(job);
4762
+ const directives = normalizeInitItems(job);
4763
+ if (directives.length === 0) return;
4764
+ const initSpecs = await expandInitDirectives(directives, {
4765
+ cloneRoot: stepCwd,
4766
+ log: (line) => maskedSend({
4767
+ type: "log.line",
4768
+ stepIndex: -1,
4769
+ line
4770
+ })
4771
+ });
4068
4772
  if (initSpecs.length === 0) return;
4069
4773
  const initResult = await runInitPhase({
4070
4774
  specs: initSpecs,
@@ -4139,6 +4843,7 @@ async function main() {
4139
4843
  });
4140
4844
  await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
4141
4845
  await applyOverlayIfRequested(request, workflowDir);
4846
+ await makeOverlayGitUsable(request, workflowDir);
4142
4847
  if (aborted) abortAndExit("aborted after clone");
4143
4848
  await installDependenciesIfNeeded(workflowDir, request);
4144
4849
  if (aborted) abortAndExit("aborted after deps");
@@ -4182,7 +4887,7 @@ async function main() {
4182
4887
  const handle = buildStepSecrets(request, masker, () => {});
4183
4888
  currentStepSecrets = handle.secrets;
4184
4889
  currentStepDispose = handle.dispose;
4185
- const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets);
4890
+ const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker);
4186
4891
  if (globalRepoInfo) {
4187
4892
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
4188
4893
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
@@ -4306,6 +5011,6 @@ main().catch((error) => {
4306
5011
  setTimeout(() => process.exit(1), 100);
4307
5012
  });
4308
5013
  //#endregion
4309
- export { rawPayloadFromEvent, resolveInitSpecs };
5014
+ export { createSandboxStepContext, rawPayloadFromEvent };
4310
5015
 
4311
5016
  //# sourceMappingURL=workflow-runner.js.map