@kici-dev/agent 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -12,14 +12,14 @@ import { RingBuffer, createHealthRoutes, createLogger, createMeter, createMetric
12
12
  import { z } from "zod";
13
13
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
14
14
  import { ALLOWED_SYSTEM_VARS, ExecutionJobStatus, ExecutionStepStatus, KNOWN_ROLES, PROTOCOL_VERSION, SANDBOX_DEFAULT_VARS, WS_CLOSE_AGENT_AUTH_FAILED, WS_MAX_PAYLOAD_BYTES, deriveOsArchLabels, heartbeatSchema, hostLabel, mergeAutoLabels, orchestratorToAgentMessageSchema, resolveRoleLabels, validateNoReservedLabels } from "@kici-dev/engine";
15
- import { execFileSync, execSync, fork, spawn } from "node:child_process";
15
+ import { execFile, execFileSync, execSync, fork, spawn } from "node:child_process";
16
16
  import WebSocket from "ws";
17
17
  import { AsyncLocalStorage } from "node:async_hooks";
18
- import { format } from "node:util";
18
+ import { format, promisify } from "node:util";
19
19
  import { existsSync } from "node:fs";
20
- import path, { dirname, join } from "node:path";
20
+ import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
- import fs, { access, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
22
+ import fs, { access, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, unlink, writeFile } from "node:fs/promises";
23
23
  import Docker from "dockerode";
24
24
  import { buildKiciApi, isDynamicGroupRef, isDynamicJobFn, isStaticArray, isStaticObject } from "@kici-dev/sdk";
25
25
  import { c, x } from "tar";
@@ -27,6 +27,7 @@ import https from "node:https";
27
27
  import http from "node:http";
28
28
  import { pipeline } from "node:stream/promises";
29
29
  import { createGunzip } from "node:zlib";
30
+ import { PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
30
31
  import { createInterface } from "node:readline";
31
32
  var __defProp = Object.defineProperty;
32
33
  var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -974,14 +975,14 @@ var init_console_capture = __esmMin((() => {
974
975
  init_console_capture();
975
976
  function safe(name, fallback = "unknown") {
976
977
  switch (name) {
977
- case "version": return "0.1.10";
978
- case "buildCommit": return "6bf528b56";
979
- case "sdkVersion": return "0.1.10";
978
+ case "version": return "0.1.12";
979
+ case "buildCommit": return "d220ac45c";
980
+ case "sdkVersion": return "0.1.12";
980
981
  case "sdkBundleHash": return "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
981
- case "sharedVersion": return "0.1.10";
982
+ case "sharedVersion": return "0.1.12";
982
983
  case "sharedBundleHash": return "36a23a454fd75ec35f258d39f254543ec0451c6b543fe3618ce427e6e6369aae";
983
- case "engineVersion": return "0.1.10";
984
- case "engineBundleHash": return "dff25bbd7602e853cec6e6e985c6c956ad773e245830273cd52950fff958d4bb";
984
+ case "engineVersion": return "0.1.12";
985
+ case "engineBundleHash": return "4ab08e63bcb8558b1191c5d3ab8d58b71950b77d911af56c9955ae1d787c4302";
985
986
  default: return fallback;
986
987
  }
987
988
  }
@@ -1628,7 +1629,7 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
1628
1629
  }
1629
1630
  var AGENT_SDK_VERSION, AGENT_SDK_BUNDLE_HASH, hookRegistered;
1630
1631
  var init_workflow_loader = __esmMin((() => {
1631
- AGENT_SDK_VERSION = "0.1.10";
1632
+ AGENT_SDK_VERSION = "0.1.12";
1632
1633
  AGENT_SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
1633
1634
  hookRegistered = false;
1634
1635
  }));
@@ -1746,8 +1747,8 @@ async function streamFetchAndExtract(url, targetDir) {
1746
1747
  * We sidestep the race entirely by extracting each attempt into a unique
1747
1748
  * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
1748
1749
  * draining writes are harmless — the next attempt does not touch them. On
1749
- * success we `fs.rename` `${scratchDir}/node_modules` into the final location
1750
- * (atomic on the same filesystem), then best-effort clean the scratch dir.
1750
+ * success `moveScratchIntoRepo` renames the extracted entries into place
1751
+ * (atomic on the same filesystem), then best-effort cleans the scratch dir.
1751
1752
  *
1752
1753
  * Scratch dirs land inside the customer's cloned working tree, so the clone
1753
1754
  * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
@@ -1783,13 +1784,58 @@ function resolveOrchestratorUrl(url) {
1783
1784
  }
1784
1785
  }
1785
1786
  /**
1786
- * Restore dependencies from a cached tarball.
1787
+ * Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
1788
+ * packed repo-root-relative, so the scratch holds repo-root entries:
1789
+ * `.kici/node_modules` for every manager, plus (for pnpm) the root
1790
+ * `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
1791
+ * already exists in the work tree (cloned or source-restored), so its children
1792
+ * are moved individually; every other top-level entry is moved wholesale.
1787
1793
  *
1788
- * For HTTP/HTTPS URLs: uses a streaming pipeline (response -> hash -> gunzip -> tar)
1789
- * with a 5-minute timeout and up to 2 retries. This avoids buffering entire tarballs
1790
- * in memory, eliminating memory spikes proportional to tarball size.
1794
+ * On a cache-hit execution agent the destinations do not pre-exist (source
1795
+ * restore excludes node_modules and never carries sibling dirs), so the renames
1796
+ * have nothing to race; the defensive `rm` covers re-runs.
1797
+ */
1798
+ async function moveScratchIntoRepo(scratchDir, workDir) {
1799
+ for (const child of await fs.readdir(scratchDir)) if (child === ".kici") {
1800
+ const kiciScratch = join(scratchDir, ".kici");
1801
+ for (const sub of await fs.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
1802
+ } else await moveInto(join(scratchDir, child), join(workDir, child));
1803
+ }
1804
+ /** Move `src` to `dest`, creating the parent and clearing any stale dest. */
1805
+ async function moveInto(src, dest) {
1806
+ await mkdir(dirname(dest), { recursive: true });
1807
+ await fs.rm(dest, {
1808
+ recursive: true,
1809
+ force: true
1810
+ });
1811
+ await fs.rename(src, dest);
1812
+ }
1813
+ /** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
1814
+ async function cleanupScratch(scratchDir) {
1815
+ try {
1816
+ await fs.rm(scratchDir, {
1817
+ recursive: true,
1818
+ force: true
1819
+ });
1820
+ } catch (cleanupErr) {
1821
+ logger$8.warn("Scratch dir cleanup failed (orphan left behind)", {
1822
+ scratchDir,
1823
+ error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
1824
+ });
1825
+ }
1826
+ }
1827
+ /**
1828
+ * Restore dependencies from a cached tarball into the cloned repo.
1829
+ *
1830
+ * The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
1831
+ * carries `.kici/node_modules`; pnpm additionally carries the root
1832
+ * `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
1833
+ * Restore extracts into a scratch dir, then moves each entry into place — one
1834
+ * code path for all managers.
1791
1835
  *
1792
- * For file:// URLs: uses a buffer-based approach (local, no streaming benefit).
1836
+ * For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
1837
+ * with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
1838
+ * For file:// URLs: a buffer-based approach (local, no streaming benefit).
1793
1839
  *
1794
1840
  * @param workDir - Root directory of the cloned repository
1795
1841
  * @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
@@ -1806,11 +1852,14 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
1806
1852
  const actualHash = computeHash(data);
1807
1853
  if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
1808
1854
  }
1809
- await extractTarball(data, kiciDir);
1855
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
1856
+ await extractTarball(data, scratchDir);
1857
+ await moveScratchIntoRepo(scratchDir, workDir);
1858
+ await cleanupScratch(scratchDir);
1810
1859
  const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
1811
1860
  logger$8.info("Dependencies restored from cache (file)", {
1812
1861
  sizeMB,
1813
- targetDir: kiciDir
1862
+ targetDir: workDir
1814
1863
  });
1815
1864
  return;
1816
1865
  }
@@ -1824,19 +1873,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
1824
1873
  try {
1825
1874
  const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
1826
1875
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
1827
- await fs.rename(join(scratchDir, "node_modules"), join(kiciDir, "node_modules"));
1828
- try {
1829
- await fs.rm(scratchDir, {
1830
- recursive: true,
1831
- force: true
1832
- });
1833
- } catch (cleanupErr) {
1834
- logger$8.warn("Scratch dir cleanup failed (orphan left behind)", {
1835
- scratchDir,
1836
- error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
1837
- });
1838
- }
1839
- logger$8.info("Dependencies restored from cache (stream)", { targetDir: kiciDir });
1876
+ await moveScratchIntoRepo(scratchDir, workDir);
1877
+ await cleanupScratch(scratchDir);
1878
+ logger$8.info("Dependencies restored from cache (stream)", { targetDir: workDir });
1840
1879
  return;
1841
1880
  } catch (err) {
1842
1881
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -2704,46 +2743,208 @@ function redactNpmOutput(input, tokens) {
2704
2743
  }
2705
2744
  var init_npm_registry_config = __esmMin((() => {}));
2706
2745
  //#endregion
2746
+ //#region src/execution/validate-kici-deps.ts
2747
+ /**
2748
+ * Pre-install validation for `.kici/` dependency specifiers.
2749
+ *
2750
+ * The agent clones a single source repository and installs its `.kici/`
2751
+ * dependencies with the repo's package manager. Local-protocol specifiers —
2752
+ * `workspace:`, `file:`, `link:`, `portal:` — resolve a dependency against
2753
+ * another package on the same filesystem rather than a registry. Whether they
2754
+ * are resolvable depends on the manager and the layout:
2755
+ *
2756
+ * - npm has no `workspace:` protocol and cannot resolve any of these from a
2757
+ * registry, so they are rejected up front with an actionable message
2758
+ * instead of the raw `EUNSUPPORTEDPROTOCOL` npm would emit.
2759
+ * - pnpm resolves `workspace:` against the repo's pnpm workspace (the agent
2760
+ * clones the whole repo, so an in-repo sibling is present), and resolves
2761
+ * `file:`/`link:`/`portal:` against a path — allowed when that path stays
2762
+ * inside the cloned repo, rejected when it escapes the clone.
2763
+ *
2764
+ * This module performs that classification so unresolvable specifiers fail
2765
+ * fast with guidance rather than a cryptic install error.
2766
+ */
2767
+ /**
2768
+ * Scan a parsed `.kici/package.json` for dependency specifiers that use a
2769
+ * local protocol (`workspace:`/`file:`/`link:`/`portal:`). Returns one entry
2770
+ * per dependency, in field order. Returns an empty array when there are none.
2771
+ */
2772
+ function findLocalProtocolDeps(pkg) {
2773
+ const found = [];
2774
+ for (const field of DEP_FIELDS) {
2775
+ const deps = pkg[field];
2776
+ if (!deps || typeof deps !== "object") continue;
2777
+ for (const [name, spec] of Object.entries(deps)) {
2778
+ if (typeof spec !== "string") continue;
2779
+ const protocol = LOCAL_PROTOCOLS.find((proto) => spec.startsWith(proto));
2780
+ if (protocol) found.push({
2781
+ name,
2782
+ spec,
2783
+ protocol
2784
+ });
2785
+ }
2786
+ }
2787
+ return found;
2788
+ }
2789
+ /** Parse `.kici/package.json`, returning `null` when it is missing or invalid. */
2790
+ async function readKiciPackageJson(kiciDir) {
2791
+ let raw;
2792
+ try {
2793
+ raw = await readFile(join(kiciDir, "package.json"), "utf-8");
2794
+ } catch {
2795
+ return null;
2796
+ }
2797
+ try {
2798
+ return JSON.parse(raw);
2799
+ } catch {
2800
+ return null;
2801
+ }
2802
+ }
2803
+ /**
2804
+ * Whether `.kici/package.json` declares any local-protocol dependency. Used to
2805
+ * decide whether the agent must build the in-repo workspace dependency closure
2806
+ * after a pnpm install (so a `workspace:` sibling's build output exists before
2807
+ * the workflow that imports it loads).
2808
+ */
2809
+ async function kiciHasLocalProtocolDeps(kiciDir) {
2810
+ const pkg = await readKiciPackageJson(kiciDir);
2811
+ if (!pkg) return false;
2812
+ return findLocalProtocolDeps(pkg).length > 0;
2813
+ }
2814
+ async function fileExists$1(target) {
2815
+ try {
2816
+ await access(target);
2817
+ return true;
2818
+ } catch {
2819
+ return false;
2820
+ }
2821
+ }
2822
+ /** Resolve a `file:`/`link:`/`portal:` spec to an absolute path under kiciDir. */
2823
+ function resolveLocalPath(kiciDir, dep) {
2824
+ const rawPath = dep.spec.slice(dep.protocol.length);
2825
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(kiciDir, rawPath);
2826
+ }
2827
+ /** Whether `target` is `repoRoot` itself or a path inside it. */
2828
+ function isInsideRepo(repoRoot, target) {
2829
+ const rel = relative(repoRoot, target);
2830
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2831
+ }
2832
+ /**
2833
+ * Classify each local-protocol dependency for the detected package manager and
2834
+ * return the ones that are unresolvable in the agent's single-clone model.
2835
+ */
2836
+ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
2837
+ if (packageManager === PackageManager.Npm) return [...deps];
2838
+ const hasWorkspaceFile = await fileExists$1(join(repoRoot, "pnpm-workspace.yaml"));
2839
+ const unresolvable = [];
2840
+ for (const dep of deps) {
2841
+ if (dep.protocol === "workspace:") {
2842
+ if (!hasWorkspaceFile) unresolvable.push(dep);
2843
+ continue;
2844
+ }
2845
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
2846
+ }
2847
+ return unresolvable;
2848
+ }
2849
+ /** Build the actionable error for unresolvable local-protocol dependencies. */
2850
+ function formatUnresolvableDepError(offenders, packageManager) {
2851
+ const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
2852
+ 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.`;
2853
+ 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.`;
2854
+ }
2855
+ /**
2856
+ * Throw an actionable error when `.kici/package.json` declares a local-protocol
2857
+ * dependency the detected package manager cannot resolve from the single cloned
2858
+ * repository. A missing or unparseable package.json is left for the install to
2859
+ * report.
2860
+ */
2861
+ async function assertResolvableDeps(args) {
2862
+ const pkg = await readKiciPackageJson(args.kiciDir);
2863
+ if (!pkg) return;
2864
+ const localDeps = findLocalProtocolDeps(pkg);
2865
+ if (localDeps.length === 0) return;
2866
+ const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot);
2867
+ if (offenders.length === 0) return;
2868
+ throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
2869
+ }
2870
+ var LOCAL_PROTOCOLS, DEP_FIELDS;
2871
+ var init_validate_kici_deps = __esmMin((() => {
2872
+ LOCAL_PROTOCOLS = [
2873
+ "workspace:",
2874
+ "file:",
2875
+ "link:",
2876
+ "portal:"
2877
+ ];
2878
+ DEP_FIELDS = [
2879
+ "dependencies",
2880
+ "devDependencies",
2881
+ "optionalDependencies",
2882
+ "peerDependencies"
2883
+ ];
2884
+ }));
2885
+ //#endregion
2707
2886
  //#region src/execution/dep-installer.ts
2708
2887
  /**
2709
2888
  * Inline dependency installation for graceful degradation.
2710
2889
  *
2711
- * When dep cache is unavailable or download fails, the agent falls back
2712
- * to running npm install directly.
2890
+ * When the dep cache is unavailable or a download fails, the agent installs
2891
+ * `.kici/` dependencies directly with the repository's package manager.
2713
2892
  *
2714
- * Only npm is supported — it ships with every Node.js installation.
2715
- * .kici/package.json is required — its presence signals deps should be installed.
2893
+ * The package manager is detected from the cloned repo (npm / pnpm); the
2894
+ * presence of `.kici/package.json` signals that deps should be installed. npm
2895
+ * is the default and ships with every Node.js install; pnpm is used when the
2896
+ * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
2897
+ * `workspace:` siblings. yarn is detected but not yet supported and is
2898
+ * rejected with an actionable error.
2716
2899
  *
2717
- * Security: npm runs with an isolated per-invocation cache directory to prevent
2718
- * cache poisoning across build jobs. A malicious package.json in one repo cannot
2719
- * taint the cache used by subsequent builds. The same pressure rules out letting
2720
- * lifecycle scripts see synthesized auth env vars — npm runs with
2721
- * `--ignore-scripts` whenever a private registry is configured.
2900
+ * Security: the install runs with an isolated per-invocation cache/store
2901
+ * directory to prevent cache poisoning across build jobs — a malicious
2902
+ * package.json in one repo cannot taint the cache used by subsequent builds.
2903
+ * The same pressure rules out letting lifecycle scripts see synthesized auth
2904
+ * env vars — the install runs with `--ignore-scripts` whenever a private
2905
+ * registry is configured.
2722
2906
  */
2723
2907
  /**
2724
- * Install dependencies inline using npm.
2725
- *
2726
- * Falls back to this when the dep cache is unavailable or download fails.
2908
+ * Detect the package manager for the cloned repo from its committed manifests.
2909
+ * A pnpm workspace's `packageManager` field + `pnpm-lock.yaml` live at the repo
2910
+ * root, so check there first; fall back to `.kici/` for a standalone
2911
+ * (non-workspace) project that carries its own lockfile; default to npm when
2912
+ * neither carries a signal. Uses the manifests-only detector so the agent's own
2913
+ * launch env (`npm_config_user_agent`) never leaks into the decision.
2914
+ */
2915
+ async function detectKiciPackageManager(repoRoot, kiciDir) {
2916
+ return await detectPackageManagerFromManifests(repoRoot) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
2917
+ }
2918
+ /**
2919
+ * Install `.kici/` dependencies inline with the repo's package manager.
2727
2920
  *
2728
- * npm runs with an isolated cache directory (created in os.tmpdir()) to prevent
2729
- * cache poisoning between build jobs. The cache is removed after installation.
2921
+ * Falls back to this when the dep cache is unavailable or a download fails.
2922
+ * The install runs with an isolated cache/store directory (created in
2923
+ * `os.tmpdir()`) to prevent cache poisoning between build jobs; the directory
2924
+ * is removed after installation.
2730
2925
  *
2731
- * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, the helper
2732
- * synthesizes a job-scoped `.kici/.npmrc` overlay for the install, restores
2733
- * the original file in `finally`, and runs npm with `--ignore-scripts` so
2734
- * lifecycle scripts in committed `package.json` cannot exfiltrate the
2735
- * synthesized token env vars.
2926
+ * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
2927
+ * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
2928
+ * and the install runs with `--ignore-scripts` so lifecycle scripts in a
2929
+ * committed `package.json` cannot exfiltrate the synthesized token env vars.
2736
2930
  *
2737
- * @param kiciDir - Path to the .kici/ directory containing package.json
2738
- * @param opts - Optional registry / installEnv configuration. When absent,
2739
- * behavior is identical to the pre-private-registry version.
2931
+ * @param kiciDir - Path to the `.kici/` directory containing package.json.
2932
+ * @param opts - Optional registry / installEnv / repoRoot configuration.
2740
2933
  */
2741
2934
  async function installDeps(kiciDir, opts = {}) {
2935
+ const repoRoot = opts.repoRoot ?? dirname(kiciDir);
2936
+ const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
2742
2937
  logger$5.info("Installing deps inline", {
2743
- packageManager: "npm",
2938
+ packageManager,
2744
2939
  dir: kiciDir
2745
2940
  });
2746
- process.stderr.write(`[dep-installer:trace] starting install: pm=npm, cwd=${kiciDir}\n`);
2941
+ process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
2942
+ 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.");
2943
+ await assertResolvableDeps({
2944
+ kiciDir,
2945
+ repoRoot,
2946
+ packageManager
2947
+ });
2747
2948
  const startTime = Date.now();
2748
2949
  const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
2749
2950
  const registryConfig = await applyNpmRegistryConfig({
@@ -2753,125 +2954,312 @@ async function installDeps(kiciDir, opts = {}) {
2753
2954
  jobIdShort: opts.jobIdShort ?? "00000000"
2754
2955
  });
2755
2956
  try {
2756
- const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
2757
- const npmCmd = "install";
2758
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
2759
- const { NODE_ENV: _, ...restEnv } = process.env;
2760
- const envWithNode = {
2761
- ...restEnv,
2762
- ...registryConfig.extraEnv,
2763
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
2764
- };
2765
- const { execFile } = await import("node:child_process");
2766
- const { promisify } = await import("node:util");
2767
- const execFileAsync = promisify(execFile);
2768
- const buildArgs = (...prefix) => {
2769
- const args = [
2770
- ...prefix,
2771
- npmCmd,
2772
- "--cache",
2773
- cacheDir,
2774
- "--no-audit",
2775
- "--no-fund"
2776
- ];
2777
- if (hasPrivateRegistry) args.push("--ignore-scripts");
2778
- return args;
2779
- };
2780
- try {
2781
- if (npmCliPath) {
2782
- process.stderr.write(`[dep-installer:trace] running: ${nodeExe} ${npmCliPath} ${buildArgs().join(" ")}\n`);
2783
- await execFileAsync(nodeExe, buildArgs(npmCliPath), {
2784
- cwd: kiciDir,
2785
- env: envWithNode,
2786
- timeout: 6e5,
2787
- maxBuffer: 128 * 1024 * 1024
2788
- });
2789
- } else {
2790
- process.stderr.write(`[dep-installer:trace] running: npm ${buildArgs().join(" ")}\n`);
2791
- await execFileAsync("npm", buildArgs(), {
2792
- cwd: kiciDir,
2793
- env: envWithNode,
2794
- timeout: 6e5,
2795
- maxBuffer: 128 * 1024 * 1024
2796
- });
2797
- }
2798
- } finally {
2799
- await rm(cacheDir, {
2800
- recursive: true,
2801
- force: true
2802
- }).catch(() => {});
2803
- }
2957
+ if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
2958
+ kiciDir,
2959
+ hasPrivateRegistry,
2960
+ registryConfig
2961
+ });
2962
+ else await runNpmInstall({
2963
+ kiciDir,
2964
+ hasPrivateRegistry,
2965
+ registryConfig
2966
+ });
2804
2967
  } catch (e) {
2805
- const msg = toErrorMessage(e);
2806
2968
  const tokens = registryConfig.tokensForRedaction;
2807
- process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(msg, tokens)}\n`);
2808
- if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
2809
- if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
2969
+ process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(toErrorMessage(e), tokens)}\n`);
2970
+ logSubprocessStreams(e, tokens);
2810
2971
  throw e;
2811
2972
  } finally {
2812
2973
  await registryConfig.cleanup();
2813
2974
  }
2975
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
2814
2976
  const durationMs = Date.now() - startTime;
2815
2977
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
2816
2978
  logger$5.info("Deps installed inline", {
2817
- packageManager: "npm",
2979
+ packageManager,
2818
2980
  durationMs
2819
2981
  });
2820
2982
  }
2821
- var logger$5;
2983
+ /** Build the Node binary directory onto PATH so spawned tools find `node`. */
2984
+ function envWithNodeOnPath(extraEnv, nodeDir) {
2985
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
2986
+ return {
2987
+ ...restEnv,
2988
+ ...extraEnv,
2989
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
2990
+ };
2991
+ }
2992
+ /** Run `npm install` in `.kici/` with an isolated cache directory. */
2993
+ async function runNpmInstall(args) {
2994
+ const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
2995
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
2996
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
2997
+ const buildArgs = (...prefix) => {
2998
+ const a = [
2999
+ ...prefix,
3000
+ "install",
3001
+ "--cache",
3002
+ cacheDir,
3003
+ "--no-audit",
3004
+ "--no-fund"
3005
+ ];
3006
+ if (args.hasPrivateRegistry) a.push("--ignore-scripts");
3007
+ return a;
3008
+ };
3009
+ try {
3010
+ const bin = npmCliPath ? nodeExe : "npm";
3011
+ const argv = npmCliPath ? buildArgs(npmCliPath) : buildArgs();
3012
+ process.stderr.write(`[dep-installer:trace] running: ${bin} ${argv.join(" ")}\n`);
3013
+ await execFileAsync(bin, argv, {
3014
+ cwd: args.kiciDir,
3015
+ env,
3016
+ timeout: INSTALL_TIMEOUT_MS,
3017
+ maxBuffer: INSTALL_MAX_BUFFER
3018
+ });
3019
+ } finally {
3020
+ await rm(cacheDir, {
3021
+ recursive: true,
3022
+ force: true
3023
+ }).catch(() => {});
3024
+ }
3025
+ }
3026
+ /**
3027
+ * Run `pnpm install` from `.kici/`. pnpm walks up to the workspace root, so a
3028
+ * `workspace:` sibling in the same cloned repo resolves. Uses an isolated store
3029
+ * (`--config.store-dir`) for cross-job isolation, `package-import-method=copy`
3030
+ * so the on-disk store is a self-contained tree of real files (a later dep
3031
+ * cache tars it), and disables interactive purge prompts + the side-effects
3032
+ * cache for deterministic, non-interactive runs.
3033
+ */
3034
+ async function runPnpmInstall(args) {
3035
+ await assertPnpmAvailable();
3036
+ const { nodeDir } = resolveNpm();
3037
+ const storeDir = await mkdtemp(join(tmpdir(), "kici-pnpm-store-"));
3038
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3039
+ const argv = [
3040
+ "install",
3041
+ `--config.store-dir=${storeDir}`,
3042
+ "--config.package-import-method=copy",
3043
+ "--config.confirm-modules-purge=false",
3044
+ "--config.side-effects-cache=false"
3045
+ ];
3046
+ if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
3047
+ try {
3048
+ process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
3049
+ await execFileAsync("pnpm", argv, {
3050
+ cwd: args.kiciDir,
3051
+ env,
3052
+ timeout: INSTALL_TIMEOUT_MS,
3053
+ maxBuffer: INSTALL_MAX_BUFFER
3054
+ });
3055
+ } finally {
3056
+ await rm(storeDir, {
3057
+ recursive: true,
3058
+ force: true
3059
+ }).catch(() => {});
3060
+ }
3061
+ }
3062
+ /**
3063
+ * Build the in-repo dependency closure of the `.kici/` package so a
3064
+ * `workspace:` sibling's build output exists before the workflow that imports
3065
+ * it loads. `--filter "{.kici}^..."` selects only `.kici`'s dependencies (not
3066
+ * `.kici` itself); `run --if-present build` skips siblings without a build
3067
+ * script. The `--if-present` flag MUST precede the script name: pnpm forwards
3068
+ * everything after the script name to the script itself, so `run build
3069
+ * --if-present` would pass `--if-present` to the build command (e.g. `tsc`)
3070
+ * and fail. Runs with a clean env (no synthesized registry tokens). On failure
3071
+ * the subprocess stderr/stdout is folded into the thrown error so the job's
3072
+ * failure message names the real cause instead of a bare "Command failed".
3073
+ */
3074
+ async function buildWorkspaceClosure(repoRoot) {
3075
+ const { nodeDir } = resolveNpm();
3076
+ const env = envWithNodeOnPath({}, nodeDir);
3077
+ const argv = [
3078
+ "--filter",
3079
+ "{.kici}^...",
3080
+ "run",
3081
+ "--if-present",
3082
+ "build"
3083
+ ];
3084
+ process.stderr.write(`[dep-installer:trace] building workspace closure: pnpm ${argv.join(" ")}\n`);
3085
+ try {
3086
+ await execFileAsync("pnpm", argv, {
3087
+ cwd: repoRoot,
3088
+ env,
3089
+ timeout: INSTALL_TIMEOUT_MS,
3090
+ maxBuffer: INSTALL_MAX_BUFFER
3091
+ });
3092
+ } catch (e) {
3093
+ logSubprocessStreams(e, []);
3094
+ throw new Error(`Failed to build .kici workspace dependency closure: ${describeExecError(e)}`);
3095
+ }
3096
+ }
3097
+ /** Build a single-line cause from an exec error's stderr/stdout, falling back to its message. */
3098
+ function describeExecError(e) {
3099
+ const pick = (k) => e && typeof e === "object" && k in e ? String(e[k]).trim() : "";
3100
+ return (pick("stderr") || pick("stdout") || toErrorMessage(e)).slice(0, 2e3);
3101
+ }
3102
+ /** Throw an actionable error when the repo needs pnpm but it is not installed. */
3103
+ async function assertPnpmAvailable() {
3104
+ try {
3105
+ await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
3106
+ } catch (e) {
3107
+ 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)})`);
3108
+ }
3109
+ }
3110
+ /** Trace the redacted stdout/stderr of a failed install subprocess. */
3111
+ function logSubprocessStreams(e, tokens) {
3112
+ if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
3113
+ if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
3114
+ }
3115
+ var logger$5, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
2822
3116
  var init_dep_installer = __esmMin((() => {
2823
3117
  init_npm_resolver();
2824
3118
  init_npm_registry_config();
3119
+ init_validate_kici_deps();
2825
3120
  logger$5 = createLogger({ prefix: "dep-installer" });
3121
+ execFileAsync = promisify(execFile);
3122
+ INSTALL_TIMEOUT_MS = 6e5;
3123
+ INSTALL_MAX_BUFFER = 128 * 1024 * 1024;
2826
3124
  }));
2827
3125
  //#endregion
2828
3126
  //#region src/execution/dep-packer.ts
2829
3127
  /**
2830
- * Node_modules tarball creation for build agents.
3128
+ * Dependency tarball creation for build agents.
3129
+ *
3130
+ * After installing dependencies the build agent packs the dependency closure
3131
+ * into a gzip tarball for upload to the dep cache. The closure is packed
3132
+ * **repo-root-relative** (cwd = the clone root) so restore is a single layout
3133
+ * regardless of package manager:
2831
3134
  *
2832
- * After installing dependencies in .kici/, packs node_modules
2833
- * into a gzip tarball for upload to the dep cache.
3135
+ * - npm / yarn: just `.kici/node_modules`.
3136
+ * - pnpm: `.kici/node_modules` plus the repo-root `node_modules/.pnpm` virtual
3137
+ * store and the in-repo `workspace:` sibling package directories `.kici`
3138
+ * depends on (with their built output). pnpm lays `.kici/node_modules` out as
3139
+ * symlinks into the root store and into sibling dirs that live outside
3140
+ * `.kici/`, so packing `.kici/node_modules` alone would capture dangling
3141
+ * links — the store and siblings must travel together.
2834
3142
  *
2835
- * Uses tar.gz format (Node.js built-in zlib, no external binary needed).
2836
- * Uses portable mode to strip user/group info for cross-machine consistency.
3143
+ * Uses tar.gz (Node.js built-in zlib, no external binary) in portable mode to
3144
+ * strip user/group info for cross-machine consistency; symlinks are preserved
3145
+ * as symlinks so the pnpm link graph restores intact.
2837
3146
  */
2838
3147
  /**
2839
- * Pack node_modules into a gzip tarball and compute its SHA-256 hash.
3148
+ * Pack the dependency closure into a gzip tarball and compute its SHA-256 hash.
2840
3149
  *
2841
- * Creates a tar.gz archive of the `node_modules/` directory relative to
2842
- * kiciDir. The tarball is created in-memory and returned as a Buffer
2843
- * along with its content hash.
2844
- *
2845
- * @param kiciDir - Path to the .kici/ directory containing node_modules/
2846
- * @returns Object with tarball Buffer and SHA-256 hash string
2847
- * @throws Error if node_modules/ does not exist in kiciDir
3150
+ * @param kiciDir - Path to the `.kici/` directory containing node_modules/.
3151
+ * @returns Object with the tarball Buffer and its SHA-256 hash string.
3152
+ * @throws Error if `.kici/node_modules` does not exist.
2848
3153
  */
2849
3154
  async function packNodeModules(kiciDir) {
2850
- const nodeModulesPath = join(kiciDir, "node_modules");
2851
- if (!existsSync(nodeModulesPath)) throw new Error(`node_modules not found at ${nodeModulesPath}`);
2852
- logger$4.info("Packing node_modules into tarball", { dir: kiciDir });
3155
+ if (!existsSync(join(kiciDir, "node_modules"))) throw new Error(`node_modules not found at ${join(kiciDir, "node_modules")}`);
3156
+ const workDir = dirname(kiciDir);
3157
+ const packageManager = await detectPackageManagerFromManifests(workDir) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
3158
+ const entries = await closureEntries(workDir, kiciDir, packageManager);
3159
+ logger$4.info("Packing dependency closure into tarball", {
3160
+ dir: workDir,
3161
+ packageManager,
3162
+ entries
3163
+ });
2853
3164
  const startTime = Date.now();
2854
3165
  const stream = c({
2855
3166
  gzip: true,
2856
- cwd: kiciDir,
3167
+ cwd: workDir,
2857
3168
  portable: true
2858
- }, ["node_modules"]);
3169
+ }, entries);
2859
3170
  const chunks = [];
2860
3171
  for await (const chunk of stream) chunks.push(Buffer.from(chunk));
2861
3172
  const tarball = Buffer.concat(chunks);
2862
3173
  const hash = sha256(tarball);
2863
3174
  const sizeMB = (tarball.length / (1024 * 1024)).toFixed(2);
2864
- const durationMs = Date.now() - startTime;
2865
- logger$4.info("Node_modules packed", {
3175
+ logger$4.info("Dependency closure packed", {
2866
3176
  sizeMB,
2867
3177
  hash: hash.slice(0, 12),
2868
- durationMs
3178
+ durationMs: Date.now() - startTime
2869
3179
  });
2870
3180
  return {
2871
3181
  tarball,
2872
3182
  hash
2873
3183
  };
2874
3184
  }
3185
+ /**
3186
+ * Compute the repo-root-relative tar entries for the dependency closure. npm /
3187
+ * yarn need only `.kici/node_modules`; pnpm additionally needs the root store
3188
+ * and the in-repo workspace siblings `.kici` resolves.
3189
+ */
3190
+ async function closureEntries(workDir, kiciDir, packageManager) {
3191
+ const kiciNodeModules = relative(workDir, join(kiciDir, "node_modules"));
3192
+ if (packageManager !== PackageManager.Pnpm) return [kiciNodeModules];
3193
+ const entries = [kiciNodeModules];
3194
+ if (existsSync(join(workDir, "node_modules", ".pnpm"))) entries.push(join("node_modules", ".pnpm"));
3195
+ for (const sibling of await collectInRepoSiblings(workDir, kiciDir)) entries.push(sibling);
3196
+ return entries;
3197
+ }
3198
+ /**
3199
+ * Walk `.kici`'s `node_modules` (and transitively each in-repo sibling's
3200
+ * `node_modules`) collecting the repo-relative directories of `workspace:`
3201
+ * siblings — package dirs that live inside the clone but outside `.kici/` and
3202
+ * outside the root `node_modules/` store. Returns each dir once.
3203
+ */
3204
+ async function collectInRepoSiblings(workDir, kiciDir) {
3205
+ const repoRoot = resolve(workDir);
3206
+ const kiciResolved = resolve(kiciDir);
3207
+ const rootNodeModules = resolve(join(workDir, "node_modules"));
3208
+ const found = /* @__PURE__ */ new Set();
3209
+ const visited = /* @__PURE__ */ new Set();
3210
+ const queue = [join(kiciDir, "node_modules")];
3211
+ while (queue.length > 0) {
3212
+ const nmDir = queue.shift();
3213
+ const real = await realpath(nmDir).catch(() => null);
3214
+ if (!real || visited.has(real)) continue;
3215
+ visited.add(real);
3216
+ for (const target of await resolveNodeModulesLinks(nmDir)) {
3217
+ if (!isInside(repoRoot, target)) continue;
3218
+ if (isInside(kiciResolved, target) || isInside(rootNodeModules, target)) continue;
3219
+ const rel = relative(workDir, target);
3220
+ if (!found.has(rel)) {
3221
+ found.add(rel);
3222
+ queue.push(join(target, "node_modules"));
3223
+ }
3224
+ }
3225
+ }
3226
+ return [...found];
3227
+ }
3228
+ /** Resolve every package symlink target under a `node_modules` dir (descending one level into `@scope` dirs). */
3229
+ async function resolveNodeModulesLinks(nmDir) {
3230
+ const targets = [];
3231
+ for (const entry of await readdir(nmDir).catch(() => [])) {
3232
+ if (entry.startsWith(".")) continue;
3233
+ const entryPath = join(nmDir, entry);
3234
+ if (entry.startsWith("@")) {
3235
+ for (const scoped of await readdir(entryPath).catch(() => [])) {
3236
+ const target = await resolveIfSymlink(join(entryPath, scoped));
3237
+ if (target) targets.push(target);
3238
+ }
3239
+ continue;
3240
+ }
3241
+ const target = await resolveIfSymlink(entryPath);
3242
+ if (target) targets.push(target);
3243
+ }
3244
+ return targets;
3245
+ }
3246
+ /** Return the real path of `p` if it is a symlink, else null. */
3247
+ async function resolveIfSymlink(p) {
3248
+ try {
3249
+ if (!(await lstat(p)).isSymbolicLink()) return null;
3250
+ return await realpath(p);
3251
+ } catch {
3252
+ return null;
3253
+ }
3254
+ }
3255
+ /** Whether `target` is `root` itself or a path inside it. */
3256
+ function isInside(root, target) {
3257
+ const rel = relative(root, target);
3258
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsoluteRel(rel);
3259
+ }
3260
+ function isAbsoluteRel(rel) {
3261
+ return rel.length > 1 && rel[1] === ":";
3262
+ }
2875
3263
  var logger$4;
2876
3264
  var init_dep_packer = __esmMin((() => {
2877
3265
  logger$4 = createLogger({ prefix: "dep-packer" });
@@ -5053,14 +5441,14 @@ var init_job_runner = __esmMin((() => {
5053
5441
  */
5054
5442
  init_console_capture();
5055
5443
  init_npm_resolver();
5056
- const AGENT_VERSION = "0.1.10";
5057
- const BUILD_COMMIT = "6bf528b56";
5058
- const SDK_VERSION = "0.1.10";
5444
+ const AGENT_VERSION = "0.1.12";
5445
+ const BUILD_COMMIT = "d220ac45c";
5446
+ const SDK_VERSION = "0.1.12";
5059
5447
  const SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
5060
- const SHARED_VERSION = "0.1.10";
5448
+ const SHARED_VERSION = "0.1.12";
5061
5449
  const SHARED_BUNDLE_HASH = "36a23a454fd75ec35f258d39f254543ec0451c6b543fe3618ce427e6e6369aae";
5062
- const ENGINE_VERSION = "0.1.10";
5063
- const ENGINE_BUNDLE_HASH = "dff25bbd7602e853cec6e6e985c6c956ad773e245830273cd52950fff958d4bb";
5450
+ const ENGINE_VERSION = "0.1.12";
5451
+ const ENGINE_BUNDLE_HASH = "4ab08e63bcb8558b1191c5d3ab8d58b71950b77d911af56c9955ae1d787c4302";
5064
5452
  initTelemetry({
5065
5453
  serviceName: "kici-agent",
5066
5454
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT