@sagentlab/navarch-runtime 0.1.12 → 0.1.13

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
@@ -254,13 +254,13 @@ unchanged across the deployment.
254
254
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
255
255
  | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
256
256
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
257
- | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. The image must contain the selected agent CLI, Git, Node.js 20+, and project build/test tools; the default is only a low-level runtime fallback and is not sufficient for BYO-key sandbox sessions ([issue #229](https://github.com/sagentlab/navarch/issues/229)). |
257
+ | `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
258
258
  | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, or `gemini`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
259
259
  | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
260
260
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
261
261
  | `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
262
262
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
263
- | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. |
263
+ | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts; supply `--setting-sources=<sources>` here to opt in deliberately. |
264
264
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
265
265
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
266
266
  | `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
@@ -280,6 +280,9 @@ supported coding agents, using each CLI's native enforcement point:
280
280
  does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
281
281
  settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
282
282
  `bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
283
+ User, project, and local settings sources are disabled by default, preventing
284
+ host-only hooks and plugins from leaking into unattended sessions; the
285
+ explicit generated settings file remains active.
283
286
  - **Codex:** the runtime passes a one-off native permission profile with
284
287
  `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
285
288
  Codex's OS sandbox grants read/write access only to the allowed roots and
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectAdapterCapacityLimit = detectAdapterCapacityLimit;
4
+ const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
5
+ const DEFAULT_CAPACITY_COOLDOWN_MS = 15 * 60 * 1000;
6
+ const RESET_GRACE_MS = 30 * 1000;
7
+ const MAX_RESET_SEARCH_MINUTES = 48 * 60;
8
+ /**
9
+ * Detects a provider-side capacity response that applies beyond one task
10
+ * lease. Claude Code reports account session exhaustion as a successful CLI
11
+ * process containing a structured 429 result, so process exit status alone
12
+ * cannot distinguish it from an ordinary failed task.
13
+ */
14
+ function detectAdapterCapacityLimit(result, nowMs = Date.now()) {
15
+ const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout) ??
16
+ (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stderr);
17
+ if (parsed?.api_error_status !== 429)
18
+ return null;
19
+ const detail = parsed.result ?? "";
20
+ if (!/\b(?:session|usage|rate)\s+limit\b|\btoo many requests\b/i.test(detail)) {
21
+ return null;
22
+ }
23
+ return {
24
+ retryAtMs: parseResetTime(detail, nowMs) ??
25
+ nowMs + DEFAULT_CAPACITY_COOLDOWN_MS,
26
+ };
27
+ }
28
+ /**
29
+ * Resolves messages such as "resets 9:50pm (America/New_York)" without
30
+ * assuming the runtime host uses the provider's timezone. Searching minute
31
+ * boundaries also handles UTC offsets and daylight-saving transitions using
32
+ * the platform's IANA timezone database.
33
+ */
34
+ function parseResetTime(detail, nowMs) {
35
+ const match = detail.match(/\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i);
36
+ if (!match)
37
+ return null;
38
+ const hour12 = Number(match[1]);
39
+ const minute = Number(match[2] ?? "0");
40
+ const meridiem = match[3]?.toLowerCase();
41
+ const timeZone = match[4]?.trim();
42
+ if (!Number.isInteger(hour12) ||
43
+ hour12 < 1 ||
44
+ hour12 > 12 ||
45
+ !Number.isInteger(minute) ||
46
+ minute < 0 ||
47
+ minute > 59 ||
48
+ !timeZone) {
49
+ return null;
50
+ }
51
+ const targetHour = hour12 % 12 + (meridiem === "pm" ? 12 : 0);
52
+ let formatter;
53
+ try {
54
+ formatter = new Intl.DateTimeFormat("en-US", {
55
+ timeZone,
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ hourCycle: "h23",
59
+ });
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ const firstMinuteMs = Math.ceil((nowMs + 1) / 60_000) * 60_000;
65
+ for (let offset = 0; offset < MAX_RESET_SEARCH_MINUTES; offset += 1) {
66
+ const candidateMs = firstMinuteMs + offset * 60_000;
67
+ const parts = formatter.formatToParts(new Date(candidateMs));
68
+ const hour = Number(parts.find((part) => part.type === "hour")?.value);
69
+ const candidateMinute = Number(parts.find((part) => part.type === "minute")?.value);
70
+ if (hour === targetHour && candidateMinute === minute) {
71
+ return candidateMs + RESET_GRACE_MS;
72
+ }
73
+ }
74
+ return null;
75
+ }
@@ -26,7 +26,22 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
26
26
  */
27
27
  async function runClaudeCodeAdapter(options) {
28
28
  const args = ["-p", options.prompt];
29
+ const hasSettingSources = options.extraArgs.some((arg) => arg === "--setting-sources" || arg.startsWith("--setting-sources="));
29
30
  const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
31
+ // Runtime sessions must not inherit an operator's personal or project-local
32
+ // Claude hooks. Apart from making execution machine-dependent, those hooks
33
+ // commonly reference helper files through CLAUDE_PROJECT_DIR; that variable
34
+ // points at the temporary session checkout, where a host-only helper does
35
+ // not exist, and a failing SessionEnd hook turns an otherwise successful
36
+ // task into an adapter failure.
37
+ //
38
+ // An empty source list disables user/project/local settings while preserving
39
+ // the explicit --settings file below ("flagSettings" in Claude Code), so the
40
+ // generated worktree-guard hook remains active. Operators can deliberately
41
+ // opt sources back in through NAVARCH_CLAUDE_EXTRA_ARGS.
42
+ if (!hasSettingSources) {
43
+ args.push("--setting-sources", "");
44
+ }
30
45
  // Navarch sessions are unattended, so route permission decisions through
31
46
  // Claude Code's native auto-mode classifier instead of prompting a human or
32
47
  // bypassing checks. Operators can replace this with a different permission
@@ -23,6 +23,7 @@ class ClaimLoop {
23
23
  claimInFlight = false;
24
24
  consecutiveFailures = 0;
25
25
  nextClaimAt = 0;
26
+ capacityCooldownUntil = 0;
26
27
  quiescenceWaiters = new Set();
27
28
  constructor(api, config, capacity, runSession) {
28
29
  this.api = api;
@@ -60,7 +61,7 @@ class ClaimLoop {
60
61
  if (this.stopped ||
61
62
  this.claimInFlight ||
62
63
  !this.capacity.hasCapacity() ||
63
- Date.now() < this.nextClaimAt)
64
+ Date.now() < Math.max(this.nextClaimAt, this.capacityCooldownUntil))
64
65
  return;
65
66
  this.claimInFlight = true;
66
67
  try {
@@ -85,6 +86,13 @@ class ClaimLoop {
85
86
  this.capacity.acquire(claimed.lease_id);
86
87
  log.info(`claimed task ${claimed.task.id} (${claimed.task.task_type}) as lease ${claimed.lease_id}, session ${sessionId}`);
87
88
  this.runSession(claimed, sessionId)
89
+ .then((outcome) => {
90
+ const cooldownUntil = outcome?.claimCooldownUntil;
91
+ if (!cooldownUntil || cooldownUntil <= Date.now())
92
+ return;
93
+ this.capacityCooldownUntil = Math.max(this.capacityCooldownUntil, cooldownUntil);
94
+ log.warn(`adapter capacity exhausted; pausing new claims until ${new Date(this.capacityCooldownUntil).toISOString()}`);
95
+ })
88
96
  .catch((err) => log.error(`session ${sessionId} failed: ${String(err)}`))
89
97
  .finally(() => this.capacity.release(claimed.lease_id));
90
98
  }
package/dist/config.cjs CHANGED
@@ -3,10 +3,13 @@ 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.DEFAULT_SANDBOX_IMAGE = void 0;
6
7
  exports.isRuntimeAgentType = isRuntimeAgentType;
7
8
  exports.loadRuntimeConfig = loadRuntimeConfig;
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const node_os_1 = __importDefault(require("node:os"));
11
+ /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
12
+ exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
10
13
  function isRuntimeAgentType(value) {
11
14
  return value === "claude-code" || value === "codex" || value === "gemini";
12
15
  }
@@ -66,7 +69,7 @@ function loadRuntimeConfig(env = process.env) {
66
69
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
67
70
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
68
71
  sandboxMode,
69
- dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
72
+ dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
70
73
  // Multiple sessions share one machine; keeping each agent inside its own
71
74
  // worktree is the safe default, so disabling is the explicit opt-out.
72
75
  worktreeGuard: !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? ""),
package/dist/session.cjs CHANGED
@@ -19,6 +19,7 @@ const logger_cjs_1 = require("./logger.cjs");
19
19
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
20
20
  const github_pr_cjs_1 = require("./github-pr.cjs");
21
21
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
22
+ const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
22
23
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
23
24
  const MCP_CONFIG_FILENAME = "mcp-config.json";
24
25
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
@@ -129,6 +130,7 @@ async function runSession(deps, claimed, sessionId) {
129
130
  let leaseLost = false;
130
131
  let leaseGone = false;
131
132
  let heartbeatInFlight = null;
133
+ const sessionOutcome = {};
132
134
  const pollLease = () => {
133
135
  if (heartbeatInFlight)
134
136
  return heartbeatInFlight;
@@ -302,6 +304,10 @@ async function runSession(deps, claimed, sessionId) {
302
304
  });
303
305
  activeAbortController = null;
304
306
  attempts.push(turnResult);
307
+ const capacityLimit = (0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(turnResult);
308
+ if (capacityLimit) {
309
+ sessionOutcome.claimCooldownUntil = Math.max(sessionOutcome.claimCooldownUntil ?? 0, capacityLimit.retryAtMs);
310
+ }
305
311
  // Close the small race between a naturally completed turn and the next
306
312
  // scheduled heartbeat. If guidance landed, run another turn before the
307
313
  // lease can be completed. A terminal heartbeat response means the
@@ -311,7 +317,7 @@ async function runSession(deps, claimed, sessionId) {
311
317
  await pollLease();
312
318
  if (leaseGone) {
313
319
  log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
314
- return;
320
+ return sessionOutcome;
315
321
  }
316
322
  if (!leaseLost && pendingGuidance.length > 0)
317
323
  continue;
@@ -383,7 +389,7 @@ async function runSession(deps, claimed, sessionId) {
383
389
  catch (err) {
384
390
  if (isAlreadyReleasedCompletionError(err)) {
385
391
  log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
386
- return;
392
+ return sessionOutcome;
387
393
  }
388
394
  const rejection = mapping.leaseOutcome === "completed" ? completionRemediationMessage(err) : null;
389
395
  if (!rejection)
@@ -432,6 +438,7 @@ async function runSession(deps, claimed, sessionId) {
432
438
  await gitWorktree.cleanup();
433
439
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
434
440
  }
441
+ return sessionOutcome;
435
442
  }
436
443
  function adapterCommand(config, runtime) {
437
444
  switch (runtime) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",