@kici-dev/agent 0.1.10 → 0.1.11

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.11";
979
+ case "buildCommit": return "b9e5aed32";
980
+ case "sdkVersion": return "0.1.11";
980
981
  case "sdkBundleHash": return "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
981
- case "sharedVersion": return "0.1.10";
982
+ case "sharedVersion": return "0.1.11";
982
983
  case "sharedBundleHash": return "36a23a454fd75ec35f258d39f254543ec0451c6b543fe3618ce427e6e6369aae";
983
- case "engineVersion": return "0.1.10";
984
- case "engineBundleHash": return "dff25bbd7602e853cec6e6e985c6c956ad773e245830273cd52950fff958d4bb";
984
+ case "engineVersion": return "0.1.11";
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.11";
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.
1791
1829
  *
1792
- * For file:// URLs: uses a buffer-based approach (local, no streaming benefit).
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.
1835
+ *
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,297 @@ 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); `--if-present` skips siblings without a build script. Runs
3067
+ * with a clean env (no synthesized registry tokens).
3068
+ */
3069
+ async function buildWorkspaceClosure(repoRoot) {
3070
+ const { nodeDir } = resolveNpm();
3071
+ const env = envWithNodeOnPath({}, nodeDir);
3072
+ const argv = [
3073
+ "--filter",
3074
+ "{.kici}^...",
3075
+ "run",
3076
+ "build",
3077
+ "--if-present"
3078
+ ];
3079
+ process.stderr.write(`[dep-installer:trace] building workspace closure: pnpm ${argv.join(" ")}\n`);
3080
+ await execFileAsync("pnpm", argv, {
3081
+ cwd: repoRoot,
3082
+ env,
3083
+ timeout: INSTALL_TIMEOUT_MS,
3084
+ maxBuffer: INSTALL_MAX_BUFFER
3085
+ });
3086
+ }
3087
+ /** Throw an actionable error when the repo needs pnpm but it is not installed. */
3088
+ async function assertPnpmAvailable() {
3089
+ try {
3090
+ await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
3091
+ } catch (e) {
3092
+ 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)})`);
3093
+ }
3094
+ }
3095
+ /** Trace the redacted stdout/stderr of a failed install subprocess. */
3096
+ function logSubprocessStreams(e, tokens) {
3097
+ if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
3098
+ if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
3099
+ }
3100
+ var logger$5, execFileAsync, INSTALL_TIMEOUT_MS, INSTALL_MAX_BUFFER;
2822
3101
  var init_dep_installer = __esmMin((() => {
2823
3102
  init_npm_resolver();
2824
3103
  init_npm_registry_config();
3104
+ init_validate_kici_deps();
2825
3105
  logger$5 = createLogger({ prefix: "dep-installer" });
3106
+ execFileAsync = promisify(execFile);
3107
+ INSTALL_TIMEOUT_MS = 6e5;
3108
+ INSTALL_MAX_BUFFER = 128 * 1024 * 1024;
2826
3109
  }));
2827
3110
  //#endregion
2828
3111
  //#region src/execution/dep-packer.ts
2829
3112
  /**
2830
- * Node_modules tarball creation for build agents.
3113
+ * Dependency tarball creation for build agents.
3114
+ *
3115
+ * After installing dependencies the build agent packs the dependency closure
3116
+ * into a gzip tarball for upload to the dep cache. The closure is packed
3117
+ * **repo-root-relative** (cwd = the clone root) so restore is a single layout
3118
+ * regardless of package manager:
2831
3119
  *
2832
- * After installing dependencies in .kici/, packs node_modules
2833
- * into a gzip tarball for upload to the dep cache.
3120
+ * - npm / yarn: just `.kici/node_modules`.
3121
+ * - pnpm: `.kici/node_modules` plus the repo-root `node_modules/.pnpm` virtual
3122
+ * store and the in-repo `workspace:` sibling package directories `.kici`
3123
+ * depends on (with their built output). pnpm lays `.kici/node_modules` out as
3124
+ * symlinks into the root store and into sibling dirs that live outside
3125
+ * `.kici/`, so packing `.kici/node_modules` alone would capture dangling
3126
+ * links — the store and siblings must travel together.
2834
3127
  *
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.
3128
+ * Uses tar.gz (Node.js built-in zlib, no external binary) in portable mode to
3129
+ * strip user/group info for cross-machine consistency; symlinks are preserved
3130
+ * as symlinks so the pnpm link graph restores intact.
2837
3131
  */
2838
3132
  /**
2839
- * Pack node_modules into a gzip tarball and compute its SHA-256 hash.
3133
+ * Pack the dependency closure into a gzip tarball and compute its SHA-256 hash.
2840
3134
  *
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
3135
+ * @param kiciDir - Path to the `.kici/` directory containing node_modules/.
3136
+ * @returns Object with the tarball Buffer and its SHA-256 hash string.
3137
+ * @throws Error if `.kici/node_modules` does not exist.
2848
3138
  */
2849
3139
  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 });
3140
+ if (!existsSync(join(kiciDir, "node_modules"))) throw new Error(`node_modules not found at ${join(kiciDir, "node_modules")}`);
3141
+ const workDir = dirname(kiciDir);
3142
+ const packageManager = await detectPackageManagerFromManifests(workDir) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
3143
+ const entries = await closureEntries(workDir, kiciDir, packageManager);
3144
+ logger$4.info("Packing dependency closure into tarball", {
3145
+ dir: workDir,
3146
+ packageManager,
3147
+ entries
3148
+ });
2853
3149
  const startTime = Date.now();
2854
3150
  const stream = c({
2855
3151
  gzip: true,
2856
- cwd: kiciDir,
3152
+ cwd: workDir,
2857
3153
  portable: true
2858
- }, ["node_modules"]);
3154
+ }, entries);
2859
3155
  const chunks = [];
2860
3156
  for await (const chunk of stream) chunks.push(Buffer.from(chunk));
2861
3157
  const tarball = Buffer.concat(chunks);
2862
3158
  const hash = sha256(tarball);
2863
3159
  const sizeMB = (tarball.length / (1024 * 1024)).toFixed(2);
2864
- const durationMs = Date.now() - startTime;
2865
- logger$4.info("Node_modules packed", {
3160
+ logger$4.info("Dependency closure packed", {
2866
3161
  sizeMB,
2867
3162
  hash: hash.slice(0, 12),
2868
- durationMs
3163
+ durationMs: Date.now() - startTime
2869
3164
  });
2870
3165
  return {
2871
3166
  tarball,
2872
3167
  hash
2873
3168
  };
2874
3169
  }
3170
+ /**
3171
+ * Compute the repo-root-relative tar entries for the dependency closure. npm /
3172
+ * yarn need only `.kici/node_modules`; pnpm additionally needs the root store
3173
+ * and the in-repo workspace siblings `.kici` resolves.
3174
+ */
3175
+ async function closureEntries(workDir, kiciDir, packageManager) {
3176
+ const kiciNodeModules = relative(workDir, join(kiciDir, "node_modules"));
3177
+ if (packageManager !== PackageManager.Pnpm) return [kiciNodeModules];
3178
+ const entries = [kiciNodeModules];
3179
+ if (existsSync(join(workDir, "node_modules", ".pnpm"))) entries.push(join("node_modules", ".pnpm"));
3180
+ for (const sibling of await collectInRepoSiblings(workDir, kiciDir)) entries.push(sibling);
3181
+ return entries;
3182
+ }
3183
+ /**
3184
+ * Walk `.kici`'s `node_modules` (and transitively each in-repo sibling's
3185
+ * `node_modules`) collecting the repo-relative directories of `workspace:`
3186
+ * siblings — package dirs that live inside the clone but outside `.kici/` and
3187
+ * outside the root `node_modules/` store. Returns each dir once.
3188
+ */
3189
+ async function collectInRepoSiblings(workDir, kiciDir) {
3190
+ const repoRoot = resolve(workDir);
3191
+ const kiciResolved = resolve(kiciDir);
3192
+ const rootNodeModules = resolve(join(workDir, "node_modules"));
3193
+ const found = /* @__PURE__ */ new Set();
3194
+ const visited = /* @__PURE__ */ new Set();
3195
+ const queue = [join(kiciDir, "node_modules")];
3196
+ while (queue.length > 0) {
3197
+ const nmDir = queue.shift();
3198
+ const real = await realpath(nmDir).catch(() => null);
3199
+ if (!real || visited.has(real)) continue;
3200
+ visited.add(real);
3201
+ for (const target of await resolveNodeModulesLinks(nmDir)) {
3202
+ if (!isInside(repoRoot, target)) continue;
3203
+ if (isInside(kiciResolved, target) || isInside(rootNodeModules, target)) continue;
3204
+ const rel = relative(workDir, target);
3205
+ if (!found.has(rel)) {
3206
+ found.add(rel);
3207
+ queue.push(join(target, "node_modules"));
3208
+ }
3209
+ }
3210
+ }
3211
+ return [...found];
3212
+ }
3213
+ /** Resolve every package symlink target under a `node_modules` dir (descending one level into `@scope` dirs). */
3214
+ async function resolveNodeModulesLinks(nmDir) {
3215
+ const targets = [];
3216
+ for (const entry of await readdir(nmDir).catch(() => [])) {
3217
+ if (entry.startsWith(".")) continue;
3218
+ const entryPath = join(nmDir, entry);
3219
+ if (entry.startsWith("@")) {
3220
+ for (const scoped of await readdir(entryPath).catch(() => [])) {
3221
+ const target = await resolveIfSymlink(join(entryPath, scoped));
3222
+ if (target) targets.push(target);
3223
+ }
3224
+ continue;
3225
+ }
3226
+ const target = await resolveIfSymlink(entryPath);
3227
+ if (target) targets.push(target);
3228
+ }
3229
+ return targets;
3230
+ }
3231
+ /** Return the real path of `p` if it is a symlink, else null. */
3232
+ async function resolveIfSymlink(p) {
3233
+ try {
3234
+ if (!(await lstat(p)).isSymbolicLink()) return null;
3235
+ return await realpath(p);
3236
+ } catch {
3237
+ return null;
3238
+ }
3239
+ }
3240
+ /** Whether `target` is `root` itself or a path inside it. */
3241
+ function isInside(root, target) {
3242
+ const rel = relative(root, target);
3243
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsoluteRel(rel);
3244
+ }
3245
+ function isAbsoluteRel(rel) {
3246
+ return rel.length > 1 && rel[1] === ":";
3247
+ }
2875
3248
  var logger$4;
2876
3249
  var init_dep_packer = __esmMin((() => {
2877
3250
  logger$4 = createLogger({ prefix: "dep-packer" });
@@ -5053,14 +5426,14 @@ var init_job_runner = __esmMin((() => {
5053
5426
  */
5054
5427
  init_console_capture();
5055
5428
  init_npm_resolver();
5056
- const AGENT_VERSION = "0.1.10";
5057
- const BUILD_COMMIT = "6bf528b56";
5058
- const SDK_VERSION = "0.1.10";
5429
+ const AGENT_VERSION = "0.1.11";
5430
+ const BUILD_COMMIT = "b9e5aed32";
5431
+ const SDK_VERSION = "0.1.11";
5059
5432
  const SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
5060
- const SHARED_VERSION = "0.1.10";
5433
+ const SHARED_VERSION = "0.1.11";
5061
5434
  const SHARED_BUNDLE_HASH = "36a23a454fd75ec35f258d39f254543ec0451c6b543fe3618ce427e6e6369aae";
5062
- const ENGINE_VERSION = "0.1.10";
5063
- const ENGINE_BUNDLE_HASH = "dff25bbd7602e853cec6e6e985c6c956ad773e245830273cd52950fff958d4bb";
5435
+ const ENGINE_VERSION = "0.1.11";
5436
+ const ENGINE_BUNDLE_HASH = "4ab08e63bcb8558b1191c5d3ab8d58b71950b77d911af56c9955ae1d787c4302";
5064
5437
  initTelemetry({
5065
5438
  serviceName: "kici-agent",
5066
5439
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT