@sagentlab/navarch-runtime 0.1.125 → 0.1.126

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/README.md CHANGED
@@ -507,6 +507,50 @@ unchanged across the deployment.
507
507
  | `NAVARCH_CONVERSATION_MIRROR` | on | Seed new conversation checkouts from a per-repository mirror under `<workspaceRoot>/conversation-mirrors/` (fetch + local clone with independent object files) instead of a full network clone. Set `off` to always clone directly. |
508
508
  | `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
509
509
  | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
510
+ | `NAVARCH_DEPENDENCY_CACHE` | `verify` | Comma-separated task types whose host-mode worktrees receive prepared `node_modules` from the lockfile-keyed dependency cache under `<workspaceRoot>/dependency-cache/` (see below). Set `off` to disable. |
511
+ | `NAVARCH_DEPENDENCY_CACHE_TIMEOUT_MS` | `600000` | Time limit for one `npm ci` cache build. A timeout records the directory as not prepared; the session still starts. |
512
+
513
+ ## Dependency cache (host mode)
514
+
515
+ Coding-agent sandboxes cannot reach a package registry, so a verify session
516
+ could not install dependencies for local tests. Before the agent starts, the
517
+ session manager (which is not sandboxed) prepares them
518
+ (`src/dependency-cache.cts`):
519
+
520
+ - It finds the tracked `package-lock.json` at the root and in first-level
521
+ directories (at most four) and handles each one separately.
522
+ - The cache key is a SHA-256 over the repository, lockfile, `package.json`,
523
+ project `.npmrc`, Node and npm versions, platform, and architecture. Entries
524
+ never cross repositories.
525
+ - It refuses repository content that could point npm at the host. Every
526
+ lockfile entry must be an HTTPS tarball with an `integrity` hash (no
527
+ `file:`, link, or git entries, lockfileVersion 2 or later). The project
528
+ `.npmrc` may set only `registry`, `@scope:registry`, and resolution rules
529
+ such as `legacy-peer-deps`. Inputs must be regular files, not symlinks.
530
+ - On a miss it runs `npm ci --ignore-scripts --strict-ssl=true
531
+ --install-links=false` in a staging directory, with a per-repository npm
532
+ cache, writes `manifest.json`, and publishes the entry by rename. npm checks
533
+ each tarball against the lockfile `integrity`. No dependency code runs on
534
+ the host. npm gets an allowlisted environment without Navarch credentials or
535
+ provider tokens, but it reads the operator's `~/.npmrc`, so private registry
536
+ credentials there are used for the download.
537
+ - It copies the entry into the worktree (copy-on-write where the filesystem
538
+ supports it), staging the copy in the session directory outside the
539
+ checkout, so a session cannot change the cached tree. The cache runs only
540
+ when the worktree guard is on, because the guard keeps the agent out of
541
+ `<workspaceRoot>/dependency-cache/`.
542
+ - It writes only an absent `node_modules` that git ignores. npm workspaces
543
+ are skipped.
544
+ - It keeps the two most recently used entries for each repository and lockfile
545
+ directory, removes entries unused for 14 days, never removes an entry used
546
+ in the last 30 minutes, and removes staging directories older than one day.
547
+ The per-repository npm tarball caches are not pruned.
548
+ - The prompt gets a **Prepared dependencies** section and the log records each
549
+ result. A failure never stops the session.
550
+
551
+ Docker mode is not covered: a tree built on the host does not match the
552
+ image's platform. The `elevated-verification` profile with an enforcing
553
+ `NAVARCH_SANDBOX_EGRESS_NETWORK` is the Docker path.
510
554
 
511
555
  ## Worktree boundary guard (host mode)
512
556
 
@@ -691,7 +735,9 @@ export NAVARCH_RUNTIMES=claude-code,codex,antigravity,opencode,acp,qoder,muse
691
735
  ```
692
736
 
693
737
  For the legacy single-runtime setting, priority is `start --agent` →
694
- `NAVARCH_AGENT` → the locally saved choice → `claude-code`.
738
+ `NAVARCH_AGENT` → the locally saved choice → `claude-code`. An unrecognized
739
+ `NAVARCH_AGENT` (for example `gemni`) is skipped with a warning that names it,
740
+ so the saved choice still applies.
695
741
  `NAVARCH_RUNTIMES` expands what the worker advertises. For normal projects,
696
742
  the locally selected `NAVARCH_AGENT` handles every unpinned task. A task with a
697
743
  runtime override (for example `antigravity`) is claimed only by a worker that
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ // The one list of adapter ids. Every other adapter union in the runtime
3
+ // (wire types in types.cts, AgentType in adapters/types.cts) derives from the
4
+ // lists below, so adding an adapter here is the only edit that widens them.
5
+ // Kept dependency-free so type-only importers (types.cts) and modules that
6
+ // config.cts itself imports can read it without a module cycle.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.AGENT_TYPES = exports.MANAGED_AGENT_TYPES = exports.RETIRED_AGENT_TYPES = exports.RUNTIME_AGENT_TYPES = void 0;
9
+ exports.isRuntimeAgentType = isRuntimeAgentType;
10
+ /**
11
+ * Adapter ids the runtime accepts for `NAVARCH_AGENT` / `--agent`, in display
12
+ * order. `NAVARCH_RUNTIMES` is validated against this same list, and the CLI
13
+ * usage/error copy is generated from it so the advertised set cannot drift.
14
+ */
15
+ exports.RUNTIME_AGENT_TYPES = [
16
+ "claude-code",
17
+ "codex",
18
+ "antigravity",
19
+ "opencode",
20
+ "acp",
21
+ "qoder",
22
+ "muse",
23
+ "pi-local",
24
+ ];
25
+ function isRuntimeAgentType(value) {
26
+ return typeof value === "string" && exports.RUNTIME_AGENT_TYPES.includes(value);
27
+ }
28
+ /**
29
+ * Retired adapters the control plane can still send (historical sessions and
30
+ * existing runtime pins). A machine never selects one locally: config.cts
31
+ * rejects `NAVARCH_AGENT=gemini` with migration guidance.
32
+ */
33
+ exports.RETIRED_AGENT_TYPES = ["gemini"];
34
+ /** Managed hosted runtimes (#885) that a BYO machine cannot advertise. */
35
+ exports.MANAGED_AGENT_TYPES = ["pi"];
36
+ /** Every adapter a session can run on, in support-matrix order. */
37
+ exports.AGENT_TYPES = [...exports.RUNTIME_AGENT_TYPES, ...exports.RETIRED_AGENT_TYPES, ...exports.MANAGED_AGENT_TYPES];
package/dist/cli.cjs CHANGED
@@ -72,12 +72,17 @@ function configFromFlags(flags, identity) {
72
72
  if (flags["config-dir"])
73
73
  env.NAVARCH_CONFIG_DIR = node_path_1.default.resolve(flags["config-dir"]);
74
74
  // Resolve the preferred adapter before deriving the advertised runtimes and
75
- // applying host guards. Keep an explicit NAVARCH_RUNTIMES list intact.
76
- const agentType = agentFromFlag(flags) ?? (env.NAVARCH_AGENT || identity?.agent_type);
75
+ // applying host guards. Keep an explicit NAVARCH_RUNTIMES list intact. An
76
+ // unrecognized NAVARCH_AGENT selects nothing, so the saved adapter still
77
+ // applies; the caller warns once with the id (warnUnknownAdapters).
78
+ const unknownAgent = (0, config_cjs_1.unknownAgentId)(env);
79
+ const agentType = agentFromFlag(flags) ?? ((unknownAgent ? undefined : env.NAVARCH_AGENT) || identity?.agent_type);
77
80
  if (agentType)
78
81
  env.NAVARCH_AGENT = agentType;
82
+ else
83
+ delete env.NAVARCH_AGENT;
79
84
  const config = (0, config_cjs_1.loadRuntimeConfig)(env);
80
- return { ...config, model: modelFromFlags(flags, config.agentType, identity) };
85
+ return { ...config, unknownAgent, model: modelFromFlags(flags, config.agentType, identity) };
81
86
  }
82
87
  function agentFromFlag(flags) {
83
88
  const value = flags.agent;
@@ -97,18 +102,25 @@ function formatChoices(values) {
97
102
  return `${quoted.slice(0, -1).join(", ")}, or ${last}`;
98
103
  }
99
104
  /**
100
- * A typo in NAVARCH_RUNTIMES must not silently shrink the advertised adapter
101
- * set, so name every dropped id on stderr. Runs once per top-level command (not
102
- * per config load) to avoid duplicate lines when start/supervise/doctor resolve
103
- * the identity and reload config. A supervised worker inherits NAVARCH_RUNTIMES
104
- * from the `supervise` parent that already warned, so it stays quiet.
105
+ * A typo in NAVARCH_AGENT or NAVARCH_RUNTIMES must not silently change the
106
+ * selected adapter or shrink the advertised set, so name every dropped id on
107
+ * stderr. Runs once per top-level command (not per config load) to avoid
108
+ * duplicate lines when start/supervise/doctor resolve the identity and reload
109
+ * config. A supervised worker inherits both variables from the `supervise`
110
+ * parent that already warned, so it stays quiet.
105
111
  */
106
- function warnUnknownRuntimes(config) {
107
- const unknown = config.unknownRuntimes;
108
- if (config.supervised || unknown.length === 0)
112
+ function warnUnknownAdapters(config) {
113
+ if (config.supervised)
109
114
  return;
110
- log.warn(`NAVARCH_RUNTIMES ignores unrecognized adapter id(s): ${unknown.join(", ")}. ` +
111
- `Known adapters: ${config_cjs_1.RUNTIME_AGENT_TYPES.join(", ")}.`);
115
+ const known = `Known adapters: ${config_cjs_1.RUNTIME_AGENT_TYPES.join(", ")}.`;
116
+ if (config.unknownAgent) {
117
+ const using = config.agentType ? ` Using ${config.agentType}.` : "";
118
+ log.warn(`NAVARCH_AGENT ignores unrecognized adapter id: '${config.unknownAgent}'.${using} ${known}`);
119
+ }
120
+ const unknown = config.unknownRuntimes;
121
+ if (unknown.length > 0) {
122
+ log.warn(`NAVARCH_RUNTIMES ignores unrecognized adapter id(s): ${unknown.join(", ")}. ${known}`);
123
+ }
112
124
  }
113
125
  /** Saved pins only apply to the adapter they were selected for. */
114
126
  function modelFromFlags(flags, agentType, identity) {
@@ -144,7 +156,7 @@ function parseArgs(argv) {
144
156
  */
145
157
  async function registerCommand(flags) {
146
158
  const config = configFromFlags(flags);
147
- warnUnknownRuntimes(config);
159
+ warnUnknownAdapters(config);
148
160
  const apiBase = flags["api-base"] ?? config.apiBase;
149
161
  const enrollmentToken = flags.token ?? process.env.NAVARCH_ENROLLMENT_TOKEN;
150
162
  const name = flags.name ?? process.env.NAVARCH_MACHINE_NAME;
@@ -195,7 +207,7 @@ async function registerCommand(flags) {
195
207
  */
196
208
  async function connectCommand(flags) {
197
209
  const config = configFromFlags(flags);
198
- warnUnknownRuntimes(config);
210
+ warnUnknownAdapters(config);
199
211
  const apiBase = flags["api-base"] ?? config.apiBase;
200
212
  const enrollmentToken = flags.token ?? process.env.NAVARCH_ENROLLMENT_TOKEN;
201
213
  const name = flags.name ?? process.env.NAVARCH_MACHINE_NAME;
@@ -239,7 +251,7 @@ async function startCommand(flags) {
239
251
  const baseConfig = configFromFlags(flags);
240
252
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
241
253
  const configuredConfig = configFromFlags(flags, identity);
242
- warnUnknownRuntimes(configuredConfig);
254
+ warnUnknownAdapters(configuredConfig);
243
255
  // Probe before the first heartbeat or claim so browser-use is never
244
256
  // advertised on a machine whose agents cannot start a browser. The verdict
245
257
  // holds for this process; restarting the runtime probes again.
@@ -346,7 +358,7 @@ async function superviseCommand(flags) {
346
358
  const baseConfig = configFromFlags(flags);
347
359
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
348
360
  const config = configFromFlags(flags, identity);
349
- warnUnknownRuntimes(config);
361
+ warnUnknownAdapters(config);
350
362
  // Resolve and pin the adapter before spawning the worker. The supervisor
351
363
  // passes machine credentials through the environment, so the child no
352
364
  // longer reads machine.json for identity fields (including agent_type).
@@ -385,14 +397,15 @@ async function doctorCommand(flags) {
385
397
  }
386
398
  if (identity)
387
399
  config = configFromFlags(flags, identity);
388
- warnUnknownRuntimes(config);
400
+ warnUnknownAdapters(config);
389
401
  }
390
402
  catch (err) {
391
403
  // The typo may be why the config is unusable (e.g. `opencode,gemni` on a
392
404
  // guarded host leaves only OpenCode), so still name it.
393
- warnUnknownRuntimes({
405
+ warnUnknownAdapters({
394
406
  supervised: process.env.NAVARCH_SUPERVISED === "1",
395
407
  unknownRuntimes: (0, config_cjs_1.unknownRuntimeIds)(process.env),
408
+ unknownAgent: (0, config_cjs_1.unknownAgentId)(process.env),
396
409
  });
397
410
  console.log(`config: UNUSABLE — ${err instanceof Error ? err.message : String(err)}`);
398
411
  process.exitCode = 1;
package/dist/config.cjs CHANGED
@@ -3,39 +3,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.OPENCODE_HOST_GUARD_MESSAGE = exports.ANTIGRAVITY_HOST_GUARD_MESSAGE = exports.RUNTIME_AGENT_TYPES = exports.DEFAULT_SANDBOX_IMAGE = void 0;
7
- exports.isRuntimeAgentType = isRuntimeAgentType;
6
+ exports.OPENCODE_HOST_GUARD_MESSAGE = exports.ANTIGRAVITY_HOST_GUARD_MESSAGE = exports.isRuntimeAgentType = exports.RUNTIME_AGENT_TYPES = exports.DEFAULT_SANDBOX_IMAGE = void 0;
7
+ exports.unknownAgentId = unknownAgentId;
8
8
  exports.unknownRuntimeIds = unknownRuntimeIds;
9
9
  exports.opencodeBlockedOnHost = opencodeBlockedOnHost;
10
10
  exports.loadRuntimeConfig = loadRuntimeConfig;
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
12
  const node_os_1 = __importDefault(require("node:os"));
13
13
  const muse_cjs_1 = require("./adapters/muse.cjs");
14
+ const agent_types_cjs_1 = require("./agent-types.cjs");
14
15
  const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
15
16
  /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
16
17
  exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
18
+ var agent_types_cjs_2 = require("./agent-types.cjs");
19
+ Object.defineProperty(exports, "RUNTIME_AGENT_TYPES", { enumerable: true, get: function () { return agent_types_cjs_2.RUNTIME_AGENT_TYPES; } });
20
+ Object.defineProperty(exports, "isRuntimeAgentType", { enumerable: true, get: function () { return agent_types_cjs_2.isRuntimeAgentType; } });
17
21
  /**
18
- * Adapter ids the runtime accepts for `NAVARCH_AGENT` / `--agent`, in display
19
- * order. `NAVARCH_RUNTIMES` is validated against this same list, and the CLI
20
- * usage/error copy is generated from it so the advertised set cannot drift.
22
+ * NAVARCH_AGENT when it is set but names no known adapter. Retired ids
23
+ * (`gemini`) are not reported here: loadRuntimeConfig rejects them with
24
+ * migration guidance.
21
25
  */
22
- exports.RUNTIME_AGENT_TYPES = [
23
- "claude-code",
24
- "codex",
25
- "antigravity",
26
- "opencode",
27
- "acp",
28
- "qoder",
29
- "muse",
30
- "pi-local",
31
- ];
32
- function isRuntimeAgentType(value) {
33
- return typeof value === "string" && exports.RUNTIME_AGENT_TYPES.includes(value);
26
+ function unknownAgentId(env) {
27
+ const value = env.NAVARCH_AGENT;
28
+ const retired = agent_types_cjs_1.RETIRED_AGENT_TYPES.includes(value ?? "");
29
+ return value && !retired && !(0, agent_types_cjs_1.isRuntimeAgentType)(value) ? value : undefined;
34
30
  }
35
31
  /** Unrecognized ids in NAVARCH_RUNTIMES, deduplicated, in the order given. */
36
32
  function unknownRuntimeIds(env) {
37
33
  const ids = envList(env, "NAVARCH_RUNTIMES", []);
38
- return [...new Set(ids.filter((runtime) => !isRuntimeAgentType(runtime)))];
34
+ return [...new Set(ids.filter((runtime) => !(0, agent_types_cjs_1.isRuntimeAgentType)(runtime)))];
39
35
  }
40
36
  function envInt(env, name, fallback) {
41
37
  const raw = env[name];
@@ -101,7 +97,7 @@ function loadRuntimeConfig(env = process.env) {
101
97
  // prerequisite for claiming ordinary shell work.
102
98
  const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
103
99
  const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell", "browser-use"];
104
- const requestedAgentType = isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
100
+ const requestedAgentType = (0, agent_types_cjs_1.isRuntimeAgentType)(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
105
101
  // Multiple sessions share one machine; keeping each agent inside its own
106
102
  // worktree is the safe default, so disabling is the explicit opt-out.
107
103
  const worktreeGuard = !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? "");
@@ -109,7 +105,7 @@ function loadRuntimeConfig(env = process.env) {
109
105
  // Keep unknown ids visible so a typo surfaces (CLI warns) instead of the
110
106
  // machine quietly shrinking the adapters it advertises.
111
107
  const unknownRuntimes = unknownRuntimeIds(env);
112
- const requestedRuntimes = requestedRuntimeIds.filter(isRuntimeAgentType);
108
+ const requestedRuntimes = requestedRuntimeIds.filter(agent_types_cjs_1.isRuntimeAgentType);
113
109
  // OpenCode's CLI cannot express the host worktree boundary, so its adapter
114
110
  // fails closed there (adapters/opencode.cts). Advertising it anyway meant
115
111
  // the machine claimed OpenCode tasks it could only fail, one lease at a
@@ -157,6 +153,7 @@ function loadRuntimeConfig(env = process.env) {
157
153
  agentType,
158
154
  runtimes: runtimes.length > 0 ? runtimes : [agentType],
159
155
  unknownRuntimes,
156
+ unknownAgent: unknownAgentId(env),
160
157
  claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
161
158
  claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
162
159
  codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
@@ -192,6 +189,10 @@ function loadRuntimeConfig(env = process.env) {
192
189
  .split(node_path_1.default.delimiter)
193
190
  .map((s) => s.trim())
194
191
  .filter(Boolean),
192
+ dependencyCacheTaskTypes: env.NAVARCH_DEPENDENCY_CACHE?.trim().toLowerCase() === "off"
193
+ ? []
194
+ : envList(env, "NAVARCH_DEPENDENCY_CACHE", ["verify"]),
195
+ dependencyCacheTimeoutMs: envInt(env, "NAVARCH_DEPENDENCY_CACHE_TIMEOUT_MS", 10 * 60_000),
195
196
  updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
196
197
  supervised: env.NAVARCH_SUPERVISED === "1",
197
198
  autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.conversationCapability = conversationCapability;
4
4
  exports.conversationSupportMatrix = conversationSupportMatrix;
5
+ const agent_types_cjs_1 = require("../agent-types.cjs");
5
6
  const index_cjs_1 = require("../adapters/index.cjs");
6
7
  /**
7
8
  * Whether this runtime can execute a Navi conversation turn, with the reason
@@ -40,8 +41,7 @@ function conversationCapability(config) {
40
41
  }
41
42
  /** Per-adapter support table for docs and diagnostics (host sandbox, guard on, feature enabled). */
42
43
  function conversationSupportMatrix() {
43
- const agents = ["claude-code", "codex", "gemini", "antigravity", "opencode", "acp", "qoder", "muse", "pi-local", "pi"];
44
- return agents.map((agentType) => ({
44
+ return agent_types_cjs_1.AGENT_TYPES.map((agentType) => ({
45
45
  agentType,
46
46
  capability: conversationCapability({
47
47
  agentType,
@@ -0,0 +1,566 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEPENDENCY_CACHE_DIRNAME = void 0;
7
+ exports.npmEnvironment = npmEnvironment;
8
+ exports.prepareDependencyCache = prepareDependencyCache;
9
+ exports.dependencyCacheKey = dependencyCacheKey;
10
+ exports.renderDependencyCacheNote = renderDependencyCacheNote;
11
+ exports.lockfileRefusal = lockfileRefusal;
12
+ exports.npmrcRefusal = npmrcRefusal;
13
+ exports.copyTree = copyTree;
14
+ const node_crypto_1 = require("node:crypto");
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ const logger_cjs_1 = require("./logger.cjs");
18
+ const sandbox_cjs_1 = require("./sandbox.cjs");
19
+ /**
20
+ * Lockfile-keyed dependency cache for host-mode sessions (issue #667).
21
+ *
22
+ * A coding-agent sandbox has no npm registry egress (worktree-guard.cts allows
23
+ * GitHub only), so a fresh worktree cannot run `npm ci`. The session manager
24
+ * is not sandboxed, so before the agent starts it installs each tracked
25
+ * `package-lock.json` once with `npm ci --ignore-scripts`, keeps the result
26
+ * under `<workspaceRoot>/dependency-cache/<key>/`, and copies that tree into
27
+ * the worktree as the ignored `node_modules` directory.
28
+ *
29
+ * Policy, in one place:
30
+ * - Key: repository, lockfile, package.json and project .npmrc bytes, Node,
31
+ * npm, platform and arch. A cache entry is never shared across
32
+ * repositories, and any input change produces a new entry.
33
+ * - Lifecycle scripts never run. Dependency code does not execute on the
34
+ * host; npm still checks every tarball against the lockfile's `integrity`.
35
+ * - Repository content cannot point npm at the host: every lockfile entry
36
+ * must be an HTTPS tarball with an integrity hash (no `file:`, link, or git
37
+ * entries), the project `.npmrc` may only choose registries and resolution
38
+ * rules, inputs must be regular files, and TLS, link and log settings are
39
+ * fixed on the command line. npm sees an allowlisted environment, but it
40
+ * does read the operator's `~/.npmrc` so private registries keep working.
41
+ * - Entries are built in a temporary directory and published by rename, so a
42
+ * partial install is never visible. The cache root is outside every
43
+ * session's guard roots, and the worktree gets a copy (copy-on-write where
44
+ * the filesystem supports it), so a session cannot change what the next
45
+ * session receives.
46
+ * - Only an ignored, absent `node_modules` is written, so the copy cannot
47
+ * become part of a commit or replace files the checkout already has.
48
+ * - npm workspaces are skipped: their install also writes into the
49
+ * workspace packages, which a root-level copy does not reproduce.
50
+ *
51
+ * Every step degrades to a recorded `skipped`/`failed` result. Dependency
52
+ * preparation never fails the session.
53
+ */
54
+ const log = (0, logger_cjs_1.createLogger)("dependency-cache");
55
+ exports.DEPENDENCY_CACHE_DIRNAME = "dependency-cache";
56
+ const MANIFEST_FILENAME = "manifest.json";
57
+ const KEY_VERSION = 1;
58
+ const LOCKFILE = "package-lock.json";
59
+ /** At most this many lockfiles per worktree; deeper or extra ones are ignored. */
60
+ const MAX_LOCKFILES = 4;
61
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
62
+ /** Entries kept per repository + lockfile directory; older ones are pruned. */
63
+ const DEFAULT_KEEP_PER_LOCKFILE = 2;
64
+ const STALE_STAGING_MS = 24 * 60 * 60_000;
65
+ /** Entries used this recently are never pruned: another session may be copying them. */
66
+ const IN_USE_GRACE_MS = 30 * 60_000;
67
+ /** Entries unused this long are pruned for any repository. */
68
+ const UNUSED_ENTRY_MS = 14 * 24 * 60 * 60_000;
69
+ /** Largest package.json / package-lock.json / .npmrc the cache reads. */
70
+ const MAX_INPUT_BYTES = 64 * 1024 * 1024;
71
+ /** Environment variables npm may see. Everything else (tokens, NAVARCH_*) is dropped. */
72
+ const NPM_ENV_ALLOWLIST = [
73
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TMPDIR",
74
+ "NVM_DIR", "NVM_BIN", "MISE_DATA_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME",
75
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
76
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
77
+ ];
78
+ function npmEnvironment(env) {
79
+ const out = {};
80
+ for (const name of NPM_ENV_ALLOWLIST) {
81
+ if (env[name] !== undefined)
82
+ out[name] = env[name];
83
+ }
84
+ out.npm_config_update_notifier = "false";
85
+ out.npm_config_fund = "false";
86
+ out.npm_config_audit = "false";
87
+ return out;
88
+ }
89
+ async function prepareDependencyCache(options) {
90
+ const runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
91
+ const npmBin = options.npmBin ?? "npm";
92
+ const env = npmEnvironment(options.env ?? process.env);
93
+ const dirs = await trackedLockfileDirs(runner, options.worktreePath);
94
+ if (dirs.length === 0)
95
+ return [];
96
+ let npmVersion = null;
97
+ const results = [];
98
+ for (const dir of dirs) {
99
+ if (options.signal?.aborted)
100
+ break;
101
+ try {
102
+ const target = node_path_1.default.join(options.worktreePath, dir, "node_modules");
103
+ if (await exists(target)) {
104
+ results.push({ dir, status: "present" });
105
+ continue;
106
+ }
107
+ if (!(await isIgnored(runner, options.worktreePath, node_path_1.default.join(dir, "node_modules")))) {
108
+ results.push({ dir, status: "skipped", detail: "node_modules is not ignored by git in this checkout" });
109
+ continue;
110
+ }
111
+ const packageJson = await readInput(node_path_1.default.join(options.worktreePath, dir, "package.json"));
112
+ const lockfile = await readInput(node_path_1.default.join(options.worktreePath, dir, LOCKFILE));
113
+ const npmrc = await readInput(node_path_1.default.join(options.worktreePath, dir, ".npmrc"));
114
+ const unreadable = [packageJson, lockfile, npmrc].find((input) => input.state === "invalid");
115
+ if (unreadable?.state === "invalid") {
116
+ results.push({ dir, status: "skipped", detail: unreadable.detail });
117
+ continue;
118
+ }
119
+ if (packageJson.state !== "file" || lockfile.state !== "file") {
120
+ results.push({ dir, status: "skipped", detail: "no package.json next to package-lock.json" });
121
+ continue;
122
+ }
123
+ if (usesWorkspaces(packageJson.data)) {
124
+ results.push({ dir, status: "skipped", detail: "npm workspaces are not supported by the dependency cache" });
125
+ continue;
126
+ }
127
+ const refusal = lockfileRefusal(lockfile.data) ?? (npmrc.state === "file" ? npmrcRefusal(npmrc.data) : null);
128
+ if (refusal) {
129
+ results.push({ dir, status: "skipped", detail: refusal });
130
+ continue;
131
+ }
132
+ npmVersion ??= await readNpmVersion(runner, npmBin, env);
133
+ if (!npmVersion) {
134
+ results.push({ dir, status: "failed", detail: "npm is not available to the runtime" });
135
+ continue;
136
+ }
137
+ const inputs = {
138
+ packageJson: packageJson.data,
139
+ lockfile: lockfile.data,
140
+ npmrc: npmrc.state === "file" ? npmrc.data : null,
141
+ };
142
+ const key = dependencyCacheKey({
143
+ repository: options.repository,
144
+ dir,
145
+ ...inputs,
146
+ npmVersion,
147
+ });
148
+ const entry = node_path_1.default.join(options.cacheRoot, key);
149
+ const build = () => buildEntry({
150
+ ...options,
151
+ runner,
152
+ npmBin,
153
+ env,
154
+ dir,
155
+ key,
156
+ entry,
157
+ files: {
158
+ "package.json": inputs.packageJson,
159
+ [LOCKFILE]: inputs.lockfile,
160
+ ...(inputs.npmrc ? { ".npmrc": inputs.npmrc } : {}),
161
+ },
162
+ manifest: {
163
+ version: KEY_VERSION,
164
+ key,
165
+ repository: options.repository,
166
+ dir,
167
+ lockfile_sha256: sha256(inputs.lockfile),
168
+ node: process.version,
169
+ npm: npmVersion,
170
+ platform: process.platform,
171
+ arch: process.arch,
172
+ lifecycle_scripts: "ignored",
173
+ created_at: new Date().toISOString(),
174
+ },
175
+ });
176
+ let status = "hit";
177
+ let failure = null;
178
+ // A second pass covers an entry another session pruned between the
179
+ // publish check and the copy: the copy fails and the entry is rebuilt.
180
+ for (let attempt = 0; attempt < 2; attempt++) {
181
+ failure = null;
182
+ if (!(await isPublished(entry))) {
183
+ failure = await build();
184
+ if (failure)
185
+ break;
186
+ status = "built";
187
+ }
188
+ await touch(node_path_1.default.join(entry, MANIFEST_FILENAME));
189
+ try {
190
+ await materialize(node_path_1.default.join(entry, "node_modules"), target, options.stagingDir);
191
+ break;
192
+ }
193
+ catch (error) {
194
+ if (attempt === 1 || (await isPublished(entry)))
195
+ throw error;
196
+ failure = "the cache entry was removed during the copy";
197
+ }
198
+ }
199
+ if (failure) {
200
+ results.push({ dir, status: "failed", key, detail: failure });
201
+ continue;
202
+ }
203
+ results.push({ dir, status, key });
204
+ await pruneEntries(options.cacheRoot, options.repository, dir, options.keepPerLockfile ?? DEFAULT_KEEP_PER_LOCKFILE)
205
+ .catch((error) => log.warn(`dependency cache prune failed: ${String(error)}`));
206
+ }
207
+ catch (error) {
208
+ results.push({ dir, status: "failed", detail: `unexpected error: ${error.message ?? String(error)}` });
209
+ }
210
+ }
211
+ return results;
212
+ }
213
+ function dependencyCacheKey(input) {
214
+ return sha256(Buffer.from(JSON.stringify({
215
+ v: KEY_VERSION,
216
+ repository: input.repository.toLowerCase(),
217
+ dir: input.dir,
218
+ lockfile: sha256(input.lockfile),
219
+ package_json: sha256(input.packageJson),
220
+ npmrc: input.npmrc ? sha256(input.npmrc) : null,
221
+ node: input.node ?? process.version,
222
+ npm: input.npmVersion,
223
+ platform: input.platform ?? process.platform,
224
+ arch: input.arch ?? process.arch,
225
+ scripts: "ignored",
226
+ })));
227
+ }
228
+ /**
229
+ * Prompt section that tells the agent what the runtime prepared. Returns null
230
+ * when the worktree has no tracked lockfile, so unrelated sessions see nothing.
231
+ */
232
+ function renderDependencyCacheNote(results) {
233
+ if (results.length === 0)
234
+ return null;
235
+ const label = (dir) => (dir === "." ? "`node_modules`" : `\`${dir}/node_modules\``);
236
+ const ready = results.filter((r) => r.status === "hit" || r.status === "built");
237
+ const notReady = results.filter((r) => r.status === "skipped" || r.status === "failed");
238
+ const lines = ["## Prepared dependencies", ""];
239
+ if (ready.length > 0) {
240
+ lines.push(`Navarch installed locked dependencies before this session started: ${ready.map((r) => `${label(r.dir)} (cache ${r.status === "hit" ? "hit" : "built"}, key \`${r.key.slice(0, 12)}\`)`).join(", ")}.`, "The runtime ran `npm ci --ignore-scripts` outside the sandbox, keyed on the repository, `package-lock.json`, `package.json`, `.npmrc`, Node, npm, and platform, and copied the result into this checkout.", "Use these directories for local tests, type checks, and builds. Do not run `npm ci` or `npm install`: the sandbox has no registry access.", "Lifecycle scripts did not run. If a step fails because a package needs its install script (for example a native addon build), report that exact package and command as a capability gap.");
241
+ }
242
+ for (const r of results.filter((result) => result.status === "present")) {
243
+ lines.push(`${label(r.dir)} was already in the checkout, so the runtime left it unchanged.`);
244
+ }
245
+ for (const r of notReady) {
246
+ lines.push(`${label(r.dir)} was not prepared (${r.status}): ${r.detail ?? "no detail"}.`);
247
+ }
248
+ return lines.join("\n");
249
+ }
250
+ async function trackedLockfileDirs(runner, worktreePath) {
251
+ const listed = await runner.run("git", ["-C", worktreePath, "ls-files", "-z", "--", LOCKFILE, `*/${LOCKFILE}`], { timeoutMs: 30_000 });
252
+ if (listed.code !== 0)
253
+ return [];
254
+ const dirs = listed.stdout
255
+ .split("\0")
256
+ .filter(Boolean)
257
+ .map((file) => node_path_1.default.posix.dirname(file))
258
+ // Root and first-level packages only, never a vendored node_modules copy.
259
+ .filter((dir) => dir === "." || (!dir.includes("/") && dir !== "node_modules"));
260
+ return [...new Set(dirs)].sort((a, b) => (a === "." ? -1 : b === "." ? 1 : a.localeCompare(b))).slice(0, MAX_LOCKFILES);
261
+ }
262
+ async function isIgnored(runner, worktreePath, relativePath) {
263
+ const result = await runner.run("git", ["-C", worktreePath, "check-ignore", "-q", "--", `${relativePath.split(node_path_1.default.sep).join("/")}/`], { timeoutMs: 30_000 });
264
+ return result.code === 0;
265
+ }
266
+ /**
267
+ * Why a lockfile cannot be installed outside the checkout, or null.
268
+ *
269
+ * npm resolves `file:` and link entries relative to the staging directory and
270
+ * reads any host path they name, so a crafted lockfile could copy host files
271
+ * (other sessions, other repositories) into this checkout. Only registry
272
+ * tarballs fetched over HTTPS and pinned by `integrity` are accepted; bundled
273
+ * dependencies ship inside such a tarball.
274
+ */
275
+ function lockfileRefusal(lockfile) {
276
+ let parsed;
277
+ try {
278
+ parsed = JSON.parse(lockfile.toString("utf8"));
279
+ }
280
+ catch {
281
+ return "package-lock.json is not valid JSON";
282
+ }
283
+ if (typeof parsed.lockfileVersion !== "number" || parsed.lockfileVersion < 2
284
+ || typeof parsed.packages !== "object" || parsed.packages === null) {
285
+ return "package-lock.json must be lockfileVersion 2 or later";
286
+ }
287
+ for (const [location, value] of Object.entries(parsed.packages)) {
288
+ if (location === "")
289
+ continue;
290
+ const entry = (typeof value === "object" && value !== null ? value : {});
291
+ const segments = location.split("/");
292
+ if (segments[0] !== "node_modules" || segments.includes("..") || segments.includes(".")) {
293
+ return `package-lock.json entry "${location.slice(0, 120)}" is outside node_modules`;
294
+ }
295
+ if (entry.link === true)
296
+ return `package-lock.json entry "${location.slice(0, 120)}" is a link`;
297
+ if (entry.inBundle === true)
298
+ continue;
299
+ if (typeof entry.resolved !== "string" || !entry.resolved.startsWith("https://")) {
300
+ return `package-lock.json entry "${location.slice(0, 120)}" does not resolve to an HTTPS registry tarball`;
301
+ }
302
+ if (typeof entry.integrity !== "string" || entry.integrity === "") {
303
+ return `package-lock.json entry "${location.slice(0, 120)}" has no integrity hash`;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+ /** Project `.npmrc` keys that only choose a registry or resolution rules. */
309
+ const NPMRC_ALLOWED_KEYS = new Set([
310
+ "registry",
311
+ "legacy-peer-deps",
312
+ "strict-peer-deps",
313
+ "engine-strict",
314
+ "save-exact",
315
+ "save-prefix",
316
+ "fund",
317
+ "audit",
318
+ "update-notifier",
319
+ ]);
320
+ /**
321
+ * Why a project `.npmrc` cannot be used by the unsandboxed npm, or null.
322
+ * Keys such as `strict-ssl`, `https-proxy`, `cafile`, `git`, `install-links`,
323
+ * or `logs-dir` would let repository content change how npm uses the host's
324
+ * network and credentials, so any key outside the allowlist skips the cache.
325
+ */
326
+ function npmrcRefusal(npmrc) {
327
+ for (const raw of npmrc.toString("utf8").split(/\r?\n/)) {
328
+ const line = raw.trim();
329
+ if (line === "" || line.startsWith("#") || line.startsWith(";"))
330
+ continue;
331
+ const key = line.split("=", 1)[0].trim();
332
+ if (NPMRC_ALLOWED_KEYS.has(key) || /^@[^/:\s]+:registry$/.test(key))
333
+ continue;
334
+ return `.npmrc sets "${key}", which the dependency cache does not allow`;
335
+ }
336
+ return null;
337
+ }
338
+ function usesWorkspaces(packageJson) {
339
+ try {
340
+ const parsed = JSON.parse(packageJson.toString("utf8"));
341
+ return parsed.workspaces !== undefined;
342
+ }
343
+ catch {
344
+ // npm ci reports an invalid package.json itself.
345
+ return false;
346
+ }
347
+ }
348
+ async function readNpmVersion(runner, npmBin, env) {
349
+ try {
350
+ const result = await runner.run(npmBin, ["--version"], { timeoutMs: 30_000, env });
351
+ const version = result.stdout.trim();
352
+ return result.code === 0 && version ? version : null;
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ }
358
+ /** Builds and publishes one entry. Returns a failure detail, or null on success. */
359
+ async function buildEntry(input) {
360
+ await node_fs_1.promises.mkdir(input.cacheRoot, { recursive: true, mode: 0o700 });
361
+ const staging = node_path_1.default.join(input.cacheRoot, `.tmp-${input.key.slice(0, 16)}-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`);
362
+ await node_fs_1.promises.mkdir(staging, { mode: 0o700 });
363
+ try {
364
+ for (const [name, contents] of Object.entries(input.files)) {
365
+ await node_fs_1.promises.writeFile(node_path_1.default.join(staging, name), contents);
366
+ }
367
+ const started = Date.now();
368
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
369
+ // npm's own tarball store is per repository too, so a private package
370
+ // fetched for one repository is never served to another.
371
+ const repositoryTag = sha256(Buffer.from(input.manifest.repository.toLowerCase())).slice(0, 16);
372
+ const result = await input.runner.run(input.npmBin, [
373
+ "ci",
374
+ "--ignore-scripts",
375
+ "--no-audit",
376
+ "--no-fund",
377
+ "--strict-ssl=true",
378
+ "--install-links=false",
379
+ "--cache",
380
+ node_path_1.default.join(input.cacheRoot, `.npm-${repositoryTag}`),
381
+ "--logs-dir",
382
+ node_path_1.default.join(input.cacheRoot, ".logs"),
383
+ ], { cwd: staging, env: input.env, timeoutMs, signal: input.signal });
384
+ if (result.code !== 0) {
385
+ log.warn(`npm ci for ${input.dir} (key ${input.key.slice(0, 12)}) exited ${result.code}: ${tail(result.stderr || result.stdout)}`);
386
+ if (input.signal?.aborted)
387
+ return "the session stopped before npm ci finished";
388
+ if (result.code === -1) {
389
+ return Date.now() - started >= timeoutMs
390
+ ? "npm ci did not finish before the dependency-cache timeout"
391
+ : "npm ci was stopped by a signal";
392
+ }
393
+ return `npm ci exited with code ${result.code}`;
394
+ }
395
+ if (!(await exists(node_path_1.default.join(staging, "node_modules")))) {
396
+ await node_fs_1.promises.mkdir(node_path_1.default.join(staging, "node_modules"));
397
+ }
398
+ for (const name of Object.keys(input.files))
399
+ await node_fs_1.promises.rm(node_path_1.default.join(staging, name), { force: true });
400
+ await node_fs_1.promises.writeFile(node_path_1.default.join(staging, MANIFEST_FILENAME), `${JSON.stringify(input.manifest, null, 2)}\n`);
401
+ try {
402
+ await node_fs_1.promises.rename(staging, input.entry);
403
+ }
404
+ catch (error) {
405
+ // Another session published the same key first; its tree is equivalent.
406
+ if (!(await isPublished(input.entry)))
407
+ throw error;
408
+ }
409
+ log.info(`built dependency cache ${input.key.slice(0, 12)} for ${input.dir} in ${Math.round((Date.now() - started) / 1000)}s`);
410
+ return null;
411
+ }
412
+ finally {
413
+ await node_fs_1.promises.rm(staging, { recursive: true, force: true }).catch(() => undefined);
414
+ }
415
+ }
416
+ /** Copies the cached tree in under a temporary name, then renames it into place. */
417
+ async function materialize(source, target, stagingDir) {
418
+ const staging = stagingDir
419
+ ? node_path_1.default.join(stagingDir, `node_modules.navarch-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`)
420
+ : `${target}.navarch-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
421
+ try {
422
+ await copyTree(source, staging);
423
+ await node_fs_1.promises.rename(staging, target);
424
+ }
425
+ finally {
426
+ await node_fs_1.promises.rm(staging, { recursive: true, force: true }).catch(() => undefined);
427
+ }
428
+ }
429
+ /** Files copied at once. A node_modules tree is tens of thousands of small files. */
430
+ const COPY_CONCURRENCY = 32;
431
+ /**
432
+ * Recursive copy with file copies in parallel, copy-on-write where the
433
+ * filesystem supports it. `fs.cp` copies one file at a time, which made a
434
+ * 1 GB tree take about 90 s instead of about 8 s. Symlinks are recreated with
435
+ * their original (relative) targets; `copyFile` keeps each file's mode.
436
+ */
437
+ async function copyTree(source, target) {
438
+ const files = [];
439
+ const links = [];
440
+ const walk = async (relative) => {
441
+ await node_fs_1.promises.mkdir(node_path_1.default.join(target, relative), { recursive: true });
442
+ const subdirs = [];
443
+ for (const dirent of await node_fs_1.promises.readdir(node_path_1.default.join(source, relative), { withFileTypes: true })) {
444
+ const child = node_path_1.default.join(relative, dirent.name);
445
+ if (dirent.isDirectory())
446
+ subdirs.push(child);
447
+ else if (dirent.isSymbolicLink())
448
+ links.push(child);
449
+ else if (dirent.isFile())
450
+ files.push(child);
451
+ }
452
+ for (const subdir of subdirs)
453
+ await walk(subdir);
454
+ };
455
+ await walk("");
456
+ let next = 0;
457
+ const worker = async () => {
458
+ for (let relative = files[next++]; relative !== undefined; relative = files[next++]) {
459
+ await node_fs_1.promises.copyFile(node_path_1.default.join(source, relative), node_path_1.default.join(target, relative), node_fs_1.constants.COPYFILE_FICLONE);
460
+ }
461
+ };
462
+ await Promise.all(Array.from({ length: COPY_CONCURRENCY }, worker));
463
+ for (const relative of links) {
464
+ await node_fs_1.promises.symlink(await node_fs_1.promises.readlink(node_path_1.default.join(source, relative)), node_path_1.default.join(target, relative));
465
+ }
466
+ }
467
+ async function pruneEntries(cacheRoot, repository, dir, keep) {
468
+ const now = Date.now();
469
+ const entries = await node_fs_1.promises.readdir(cacheRoot, { withFileTypes: true });
470
+ const matching = [];
471
+ const expired = [];
472
+ for (const dirent of entries) {
473
+ if (dirent.isDirectory() && dirent.name.startsWith(".tmp-")) {
474
+ // Staging left behind by a crashed runtime; a live build is far younger.
475
+ const staging = node_path_1.default.join(cacheRoot, dirent.name);
476
+ const stat = await node_fs_1.promises.stat(staging).catch(() => null);
477
+ if (stat && now - stat.mtimeMs > STALE_STAGING_MS) {
478
+ await node_fs_1.promises.rm(staging, { recursive: true, force: true }).catch(() => undefined);
479
+ }
480
+ continue;
481
+ }
482
+ if (!dirent.isDirectory() || dirent.name.startsWith("."))
483
+ continue;
484
+ const entry = node_path_1.default.join(cacheRoot, dirent.name);
485
+ const manifestPath = node_path_1.default.join(entry, MANIFEST_FILENAME);
486
+ try {
487
+ const manifest = JSON.parse(await node_fs_1.promises.readFile(manifestPath, "utf8"));
488
+ const usedAt = (await node_fs_1.promises.stat(manifestPath)).mtimeMs;
489
+ // An entry another session touched recently may be mid-copy.
490
+ if (now - usedAt < IN_USE_GRACE_MS)
491
+ continue;
492
+ if (now - usedAt > UNUSED_ENTRY_MS)
493
+ expired.push(entry);
494
+ else if (manifest.repository?.toLowerCase() === repository.toLowerCase() && manifest.dir === dir) {
495
+ matching.push({ entry, usedAt });
496
+ }
497
+ }
498
+ catch {
499
+ continue;
500
+ }
501
+ }
502
+ // `keep` counts the entry this session just used, which the grace period skipped.
503
+ matching.sort((a, b) => b.usedAt - a.usedAt);
504
+ const stale = [...matching.slice(Math.max(keep - 1, 0)).map((m) => m.entry), ...expired];
505
+ for (const entry of stale) {
506
+ // Rename first so a concurrent reader never sees a half-deleted entry as published.
507
+ const doomed = node_path_1.default.join(cacheRoot, `.tmp-prune-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`);
508
+ try {
509
+ await node_fs_1.promises.rename(entry, doomed);
510
+ }
511
+ catch (error) {
512
+ if (error.code === "ENOENT")
513
+ continue; // Another prune won.
514
+ throw error;
515
+ }
516
+ await node_fs_1.promises.rm(doomed, { recursive: true, force: true });
517
+ }
518
+ }
519
+ async function isPublished(entry) {
520
+ return (await exists(node_path_1.default.join(entry, MANIFEST_FILENAME))) && (await exists(node_path_1.default.join(entry, "node_modules")));
521
+ }
522
+ async function exists(target) {
523
+ try {
524
+ await node_fs_1.promises.lstat(target);
525
+ return true;
526
+ }
527
+ catch {
528
+ return false;
529
+ }
530
+ }
531
+ /**
532
+ * Reads an install input without following a symlink, so a planted link
533
+ * cannot stage a host file, block on a FIFO, or stream /dev/zero.
534
+ */
535
+ async function readInput(file) {
536
+ let stat;
537
+ try {
538
+ stat = await node_fs_1.promises.lstat(file);
539
+ }
540
+ catch {
541
+ return { state: "absent" };
542
+ }
543
+ const name = node_path_1.default.basename(file);
544
+ if (!stat.isFile())
545
+ return { state: "invalid", detail: `${name} is not a regular file` };
546
+ if (stat.size > MAX_INPUT_BYTES)
547
+ return { state: "invalid", detail: `${name} is larger than 64 MB` };
548
+ const handle = await node_fs_1.promises.open(file, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW ?? 0));
549
+ try {
550
+ return { state: "file", data: await handle.readFile() };
551
+ }
552
+ finally {
553
+ await handle.close();
554
+ }
555
+ }
556
+ async function touch(file) {
557
+ const now = new Date();
558
+ await node_fs_1.promises.utimes(file, now, now).catch(() => undefined);
559
+ }
560
+ function sha256(data) {
561
+ return (0, node_crypto_1.createHash)("sha256").update(data).digest("hex");
562
+ }
563
+ function tail(text) {
564
+ const trimmed = text.trim();
565
+ return trimmed.length > 500 ? `…${trimmed.slice(-500)}` : trimmed;
566
+ }
@@ -8,7 +8,7 @@ exports.loadMachineIdentity = loadMachineIdentity;
8
8
  exports.resolveMachineIdentity = resolveMachineIdentity;
9
9
  const node_fs_1 = require("node:fs");
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
- const config_cjs_1 = require("./config.cjs");
11
+ const agent_types_cjs_1 = require("./agent-types.cjs");
12
12
  function storePath(configDir) {
13
13
  return node_path_1.default.join(configDir, "machine.json");
14
14
  }
@@ -52,7 +52,7 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
52
52
  // Validate against the one accepted adapter list so a new adapter cannot
53
53
  // be added to `--agent`/NAVARCH_AGENT while this env-based identity path
54
54
  // silently drops it.
55
- agent_type: (0, config_cjs_1.isRuntimeAgentType)(process.env.NAVARCH_AGENT)
55
+ agent_type: (0, agent_types_cjs_1.isRuntimeAgentType)(process.env.NAVARCH_AGENT)
56
56
  ? process.env.NAVARCH_AGENT
57
57
  : undefined,
58
58
  };
package/dist/session.cjs CHANGED
@@ -22,6 +22,7 @@ const mcp_config_cjs_1 = require("./mcp-config.cjs");
22
22
  const user_mcp_servers_cjs_1 = require("./user-mcp-servers.cjs");
23
23
  const logger_cjs_1 = require("./logger.cjs");
24
24
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
25
+ const dependency_cache_cjs_1 = require("./dependency-cache.cjs");
25
26
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
26
27
  const session_processes_cjs_1 = require("./session-processes.cjs");
27
28
  const github_pr_cjs_1 = require("./github-pr.cjs");
@@ -523,6 +524,43 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
523
524
  promptText = `${promptText}\n\n${(0, prompt_cjs_1.renderHostedCheckpointNote)(restoredCheckpoint, gitWorktree.branch)}`;
524
525
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
525
526
  }
527
+ // The sandbox cannot reach a package registry, so the unsandboxed session
528
+ // manager installs locked dependencies for the selected task types
529
+ // (dependency-cache.cts). Host mode only: a host-built tree does not match
530
+ // a Docker image's platform. The worktree guard is what keeps the agent
531
+ // out of the shared cache, so an unguarded session gets no cache.
532
+ if (config.sandboxMode === "host" && config.worktreeGuard
533
+ && config.dependencyCacheTaskTypes.includes(task.task_type)) {
534
+ const dependencyAbort = new AbortController();
535
+ activeAbortController = dependencyAbort;
536
+ if (leaseLost || deps.shutdownSignal?.aborted)
537
+ dependencyAbort.abort();
538
+ const dependencyResults = await (0, dependency_cache_cjs_1.prepareDependencyCache)({
539
+ cacheRoot: node_path_1.default.join(config.workspaceRoot, dependency_cache_cjs_1.DEPENDENCY_CACHE_DIRNAME),
540
+ worktreePath: gitWorktree.worktreePath,
541
+ stagingDir: workDir,
542
+ repository: task.repo,
543
+ timeoutMs: config.dependencyCacheTimeoutMs,
544
+ signal: dependencyAbort.signal,
545
+ }).catch((error) => {
546
+ log.warn(`dependency cache for ${leaseId} failed: ${(0, redact_cjs_1.redactText)(String(error), registry.list())}`);
547
+ return [];
548
+ });
549
+ activeAbortController = null;
550
+ for (const result of dependencyResults) {
551
+ const message = `dependency cache for ${leaseId} ${result.dir}: ${result.status}` +
552
+ `${result.key ? ` (key ${result.key.slice(0, 12)})` : ""}${result.detail ? ` — ${result.detail}` : ""}`;
553
+ if (result.status === "failed")
554
+ log.warn(message);
555
+ else
556
+ log.info(message);
557
+ }
558
+ const dependencyNote = (0, dependency_cache_cjs_1.renderDependencyCacheNote)(dependencyResults);
559
+ if (dependencyNote) {
560
+ promptText = `${promptText}\n\n${dependencyNote}`;
561
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
562
+ }
563
+ }
526
564
  // Preparation may resume an existing task branch with an older slug or ID format.
527
565
  if (!allowBranchRename) {
528
566
  sessionEnv.NAVARCH_DELIVERY_BRANCH = gitWorktree.branch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.125",
3
+ "version": "0.1.126",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",