@sagentlab/navarch-runtime 0.1.15 → 0.1.18

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
@@ -6,10 +6,10 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
6
6
  — this directory is a self-contained package you can `npx` on any fresh
7
7
  machine.
8
8
 
9
- The control plane selects **Claude Code**, **OpenAI Codex**, or **Google
10
- Gemini CLI** from the project's default and any task override. Workers
11
- advertise the adapters they can run, so tasks wait for an eligible runtime
12
- and capability match — see "Choosing an agent" below.
9
+ The machine operator selects **Claude Code**, **OpenAI Codex**, or **Google
10
+ Gemini CLI** when connecting a worker. Any selected agent can run any project
11
+ task; task eligibility depends on capabilities and project gates, not agent
12
+ type — see "Choosing an agent" below.
13
13
 
14
14
  See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
15
15
  §3.8/§3.9/§3.11 and [`docs/navarch/implementation-plan.md`](../docs/navarch/implementation-plan.md)
@@ -74,6 +74,22 @@ the runtime. The UI-generated npm commands remain the source of truth for the
74
74
  token, project, agent, capacity, API origin, and config directory; translate
75
75
  those same options to `node bin/navarch.cjs` for a source install.
76
76
 
77
+ ### Releasing a runtime change
78
+
79
+ Publication is triggered by the version, not by the code: a push to `main` that
80
+ changes `runtime/package.json` publishes that exact version of
81
+ `@sagentlab/navarch-runtime` and rolls it out. So a pull request that edits
82
+ `runtime/src/**` or `runtime/bin/**` without bumping the version merges green
83
+ and never reaches a machine. Bump `runtime/package.json` and
84
+ `runtime/package-lock.json` to the same new version in the pull request that
85
+ carries the change; CI's `Runtime` job fails the pull request when that bump is
86
+ missing, when the two manifests disagree, or when the version is one the base
87
+ branch tip or npm already carries.
88
+
89
+ [`playbooks/runtime-releases.md`](../playbooks/runtime-releases.md) is the full
90
+ release contract — the guard's exact cases, the post-merge duplicate backstop,
91
+ and the recovery steps.
92
+
77
93
  The operator-only `register` command enrolls a globally managed machine. It is
78
94
  not the normal public onboarding path. `register` prints the machine auth token
79
95
  once and stores the same `machine.json` identity used by `connect`. For
@@ -248,7 +264,7 @@ unchanged across the deployment.
248
264
  | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
249
265
  | `NAVARCH_CAPABILITIES` | `shell,browser-use` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. Docker mode does not advertise browser use until the configured image provides it. |
250
266
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
251
- | `NAVARCH_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
267
+ | `NAVARCH_POLL_INTERVAL_MS` | `30000` | Claim-loop poll interval. |
252
268
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
253
269
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
254
270
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
@@ -289,7 +305,12 @@ supported coding agents, using each CLI's native enforcement point:
289
305
  - **Codex:** the runtime passes a one-off native permission profile with
290
306
  `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
291
307
  Codex's OS sandbox grants read/write access only to the allowed roots and
292
- denies the surrounding multi-session workspace, while eligible escalations
308
+ denies the surrounding multi-session workspace. Its network policy allows
309
+ GitHub and GitHub-hosted content so authenticated `gh` and git operations
310
+ required by the task stay inside the sandbox. `gh` reads from an empty,
311
+ session-owned `GH_CONFIG_DIR` and authenticates with the lease-scoped token,
312
+ so the operator's GitHub configuration and unrelated credentials remain
313
+ inaccessible. Other destinations remain blocked and eligible escalations
293
314
  are decided by the automatic reviewer rather than waiting for human input.
294
315
  `--ignore-user-config` and an untrusted project-config override prevent a
295
316
  user or checked-in legacy `sandbox_mode` from silently disabling the
@@ -314,8 +335,10 @@ The resulting boundary is:
314
335
  system prefixes.
315
336
  - The **rest of the workspace root** — sibling sessions' worktrees, other
316
337
  projects' bare repos, and the session's own metadata dir (lease-scoped MCP
317
- config, the guard files themselves) — is denied outright, so an agent can
318
- neither read another agent's checkout nor rewrite its own guard policy.
338
+ config, the guard files themselves) — is denied outright. The only metadata
339
+ exception is the empty, read-only `gh` config directory described above, so
340
+ an agent can neither read another agent's checkout nor rewrite its own guard
341
+ policy.
319
342
 
320
343
  The Claude hook is a strong guardrail rather than a hard security boundary
321
344
  because shell paths are screened lexically. Codex's permission profile is
@@ -354,15 +377,15 @@ export NAVARCH_AGENT=codex
354
377
  # NAVARCH_GEMINI_BIN pointing at it).
355
378
  export NAVARCH_AGENT=gemini
356
379
 
357
- # Or let one worker serve tasks selected for any installed adapter.
380
+ # Advanced compatibility mode: advertise every installed adapter.
358
381
  export NAVARCH_RUNTIMES=claude-code,codex,gemini
359
382
  ```
360
383
 
361
384
  For the legacy single-runtime setting, priority is `start --agent` →
362
385
  `NAVARCH_AGENT` → the locally saved choice → `claude-code`.
363
- `NAVARCH_RUNTIMES` expands what the worker advertises; the control plane then
364
- resolves `tasks.runtime_override` `projects.default_runtime` and returns the
365
- selected adapter with the claim.
386
+ `NAVARCH_RUNTIMES` expands what the worker advertises. For normal projects,
387
+ the locally selected `NAVARCH_AGENT` handles every claimed task. Sandbox
388
+ projects remain constrained to Claude Code.
366
389
 
367
390
  All three adapters implement the same `AgentAdapter` interface
368
391
  (`src/adapters/types.cts`) and run either directly on the host or via
package/dist/api.cjs CHANGED
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NavarchApiClient = exports.NavarchTransportError = exports.NavarchApiError = void 0;
4
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
5
+ const MAX_ERROR_CAUSE_DEPTH = 4;
6
+ const MAX_AGGREGATE_ERRORS = 4;
4
7
  class NavarchApiError extends Error {
5
8
  status;
6
9
  body;
@@ -34,10 +37,12 @@ class NavarchApiClient {
34
37
  baseUrl;
35
38
  token;
36
39
  fetchImpl;
40
+ requestTimeoutMs;
37
41
  constructor(opts) {
38
42
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
39
43
  this.token = opts.token;
40
44
  this.fetchImpl = opts.fetchImpl ?? fetch;
45
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
41
46
  }
42
47
  /** The control-plane base URL this client talks to (used to build the per-session platform MCP config -- see mcp-config.cts). */
43
48
  getBaseUrl() {
@@ -57,29 +62,41 @@ class NavarchApiClient {
57
62
  headers.authorization = `Bearer ${this.token}`;
58
63
  }
59
64
  const requestUrl = `${this.baseUrl}${pathname}`;
60
- let response;
65
+ const abortController = new AbortController();
66
+ const requestTimeout = setTimeout(() => {
67
+ const timeoutError = new Error(`request timed out after ${this.requestTimeoutMs}ms`);
68
+ timeoutError.name = "TimeoutError";
69
+ abortController.abort(timeoutError);
70
+ }, this.requestTimeoutMs);
61
71
  try {
62
- response = await this.fetchImpl(requestUrl, {
72
+ const response = await this.fetchImpl(requestUrl, {
63
73
  method,
64
74
  headers,
65
75
  body: body === undefined ? undefined : JSON.stringify(body),
76
+ signal: abortController.signal,
66
77
  });
78
+ if (response.status === 204)
79
+ return null;
80
+ const text = await response.text();
81
+ const parsed = text ? safeJsonParse(text) : null;
82
+ if (!response.ok) {
83
+ throw new NavarchApiError(`Navarch API ${method} ${pathname} failed with ${response.status}`, response.status, parsed ?? text);
84
+ }
85
+ if (opts?.allowEmpty && parsed === null)
86
+ return null;
87
+ return parsed;
67
88
  }
68
89
  catch (err) {
90
+ if (err instanceof NavarchApiError)
91
+ throw err;
69
92
  // Include enough request/cause context to diagnose DNS, connection, and
70
- // TLS failures. Deliberately omit headers and URL credentials/query data.
93
+ // TLS/body-stream failures. Deliberately omit headers and URL
94
+ // credentials/query data.
71
95
  throw new NavarchTransportError(method, safeEndpoint(requestUrl, pathname), err);
72
96
  }
73
- if (response.status === 204)
74
- return null;
75
- const text = await response.text();
76
- const parsed = text ? safeJsonParse(text) : null;
77
- if (!response.ok) {
78
- throw new NavarchApiError(`Navarch API ${method} ${pathname} failed with ${response.status}`, response.status, parsed ?? text);
97
+ finally {
98
+ clearTimeout(requestTimeout);
79
99
  }
80
- if (opts?.allowEmpty && parsed === null)
81
- return null;
82
- return parsed;
83
100
  }
84
101
  /** Global machine registration using the operator-configured enrollment secret. */
85
102
  async registerMachine(req) {
@@ -163,16 +180,26 @@ function safeEndpoint(requestUrl, pathname) {
163
180
  return pathname;
164
181
  }
165
182
  }
166
- function describeError(err) {
183
+ function describeError(err, seen = new Set(), depth = 0) {
167
184
  if (!(err instanceof Error))
168
185
  return String(err);
169
- let detail = `${err.name}: ${err.message}`;
170
- const cause = err.cause;
171
- if (cause instanceof Error) {
172
- detail += `; cause: ${cause.name}: ${cause.message}`;
173
- }
174
- else if (cause && typeof cause === "object" && "code" in cause) {
175
- detail += `; cause code: ${String(cause.code)}`;
186
+ if (seen.has(err))
187
+ return "[circular error cause]";
188
+ const detail = err.message ? `${err.name}: ${err.message}` : err.name;
189
+ if (depth >= MAX_ERROR_CAUSE_DEPTH)
190
+ return detail;
191
+ seen.add(err);
192
+ const nested = [];
193
+ if (err.cause !== undefined) {
194
+ nested.push(`cause: ${describeError(err.cause, seen, depth + 1)}`);
195
+ }
196
+ if (err instanceof AggregateError) {
197
+ const errors = Array.from(err.errors).slice(0, MAX_AGGREGATE_ERRORS);
198
+ if (errors.length > 0) {
199
+ nested.push(`errors: [${errors
200
+ .map((child) => describeError(child, seen, depth + 1))
201
+ .join("; ")}]`);
202
+ }
176
203
  }
177
- return detail;
204
+ return nested.length > 0 ? `${detail}; ${nested.join("; ")}` : detail;
178
205
  }
package/dist/config.cjs CHANGED
@@ -20,6 +20,16 @@ function envInt(env, name, fallback) {
20
20
  const n = Number(raw);
21
21
  return Number.isFinite(n) && n > 0 ? n : fallback;
22
22
  }
23
+ /** Default `$CODEX_HOME`, matching the Codex CLI's own resolution. */
24
+ function defaultCodexHome(env) {
25
+ return env.CODEX_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".codex");
26
+ }
27
+ /** Claude Code's global config sits beside the home dir, or under `CLAUDE_CONFIG_DIR`. */
28
+ function defaultClaudeConfigPath(env) {
29
+ return env.CLAUDE_CONFIG_DIR
30
+ ? node_path_1.default.join(env.CLAUDE_CONFIG_DIR, ".claude.json")
31
+ : node_path_1.default.join(node_os_1.default.homedir(), ".claude.json");
32
+ }
23
33
  function envList(env, name, fallback) {
24
34
  const raw = env[name];
25
35
  if (!raw)
@@ -51,7 +61,7 @@ function loadRuntimeConfig(env = process.env) {
51
61
  maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS", 5),
52
62
  capabilities: envList(env, "NAVARCH_CAPABILITIES", defaultCapabilities),
53
63
  ownerZone: env.NAVARCH_OWNER_ZONE ?? "sagentlab",
54
- pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 5000),
64
+ pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 30_000),
55
65
  machineHeartbeatIntervalMs: envInt(env, "NAVARCH_HEARTBEAT_INTERVAL_MS", 60_000),
56
66
  // leases.expires_at = claimed_at + 15 min (schema-design.md §4) — default renewal
57
67
  // interval must stay comfortably under that TTL.
@@ -82,5 +92,10 @@ function loadRuntimeConfig(env = process.env) {
82
92
  updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
83
93
  autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
84
94
  !["off", "false", "0"].includes(env.NAVARCH_AUTO_UPDATE ?? ""),
95
+ // Reads only the quota numbers providers already write to disk, so it is on
96
+ // by default; operators who would rather share nothing can opt out.
97
+ reportUsageLimits: !["off", "false", "0"].includes(env.NAVARCH_USAGE_LIMITS ?? ""),
98
+ codexHome: defaultCodexHome(env),
99
+ claudeConfigPath: defaultClaudeConfigPath(env),
85
100
  };
86
101
  }
@@ -175,7 +175,7 @@ function summarize(text, maxLen = 500) {
175
175
  function mapExitCondition(result) {
176
176
  // Agent output is untrusted evidence, including the final completion
177
177
  // message. session.cts resolves PR evidence independently through GitHub
178
- // using this session's exact repository and worktree head branch.
178
+ // using the session repository and the worktree's live branch/commit.
179
179
  const parsedJson = parseClaudeJsonResult(result.stdout);
180
180
  const evidenceUrls = [];
181
181
  if (result.killedByLeaseLoss) {
@@ -127,6 +127,28 @@ class GitWorktree {
127
127
  await this.runGit(["-C", this.worktreePath, "remote", "add", "origin", this.cloneUrl], false);
128
128
  }
129
129
  }
130
+ /**
131
+ * Resolves the worktree's current branch and commit after an agent turn.
132
+ *
133
+ * Agents may check out an existing PR branch or rename the generated session
134
+ * branch. Completion evidence must follow that live HEAD rather than the
135
+ * immutable branch name allocated when the worktree was created.
136
+ */
137
+ async resolveCurrentHead() {
138
+ const [branchResult, shaResult] = await Promise.all([
139
+ this.runGit(["-C", this.worktreePath, "rev-parse", "--abbrev-ref", "HEAD"], false),
140
+ this.runGit(["-C", this.worktreePath, "rev-parse", "--verify", "HEAD"], false),
141
+ ]);
142
+ const branch = branchResult.stdout.trim();
143
+ const sha = shaResult.stdout.trim();
144
+ if (!/^[0-9a-f]{40,64}$/i.test(sha)) {
145
+ throw new Error(`git rev-parse returned an invalid HEAD SHA: ${sha || "<empty>"}`);
146
+ }
147
+ return {
148
+ branch: branch && branch !== "HEAD" ? branch : null,
149
+ sha,
150
+ };
151
+ }
130
152
  async cleanup() {
131
153
  await withRepositoryLock(this.repositoryPath, async () => {
132
154
  await this.runner
@@ -22,21 +22,49 @@ async function resolveGitHubToken(options) {
22
22
  return undefined;
23
23
  }
24
24
  }
25
+ function matchingPullRequestUrl(body, repository, matchesHead) {
26
+ if (!Array.isArray(body)) {
27
+ throw new Error("GitHub pull request lookup returned a non-array response");
28
+ }
29
+ const matches = body.filter((candidate) => {
30
+ if (!matchesHead(candidate))
31
+ return false;
32
+ const headRepository = candidate.head?.repo?.full_name;
33
+ if (typeof headRepository !== "string")
34
+ return false;
35
+ if (headRepository.toLowerCase() !== repository)
36
+ return false;
37
+ if (typeof candidate.html_url !== "string")
38
+ return false;
39
+ return isPullRequestUrlForRepository(candidate.html_url, repository);
40
+ });
41
+ const candidate = matches.find((pullRequest) => pullRequest.state === "open") ?? matches[0];
42
+ return typeof candidate?.html_url === "string" ? candidate.html_url : null;
43
+ }
44
+ async function fetchPullRequests(url, headers, fetchImpl) {
45
+ const response = await fetchImpl(url, { headers });
46
+ if (!response.ok) {
47
+ throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
48
+ }
49
+ return response.json();
50
+ }
25
51
  /**
26
- * Finds the PR opened from this session's exact same-repository head branch.
27
- * GitHub's head filter narrows the response, and the response is checked again
28
- * before its URL is trusted so another repository or branch can never be
29
- * attached as completion evidence.
52
+ * Finds a same-repository PR for the worktree's current head.
53
+ *
54
+ * The exact branch remains the first and cheapest lookup. If an agent checked
55
+ * out or renamed a branch, the current commit provides a secure fallback:
56
+ * GitHub must report a PR whose head SHA and head repository exactly match the
57
+ * local worktree before its URL is accepted as completion evidence.
30
58
  */
31
59
  async function findHeadBranchPullRequestUrl(options) {
32
60
  const [owner, name, ...extra] = options.repository.split("/");
33
61
  if (!owner || !name || extra.length > 0) {
34
62
  throw new Error(`Invalid GitHub repository name: ${options.repository}`);
35
63
  }
36
- const url = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
37
- url.searchParams.set("state", "all");
38
- url.searchParams.set("head", `${owner}:${options.headBranch}`);
39
- url.searchParams.set("per_page", "10");
64
+ const branchUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
65
+ branchUrl.searchParams.set("state", "all");
66
+ branchUrl.searchParams.set("head", `${owner}:${options.headBranch}`);
67
+ branchUrl.searchParams.set("per_page", "10");
40
68
  const headers = {
41
69
  accept: "application/vnd.github+json",
42
70
  "user-agent": "navarch-runtime",
@@ -45,30 +73,22 @@ async function findHeadBranchPullRequestUrl(options) {
45
73
  const githubToken = await resolveGitHubToken(options);
46
74
  if (githubToken)
47
75
  headers.authorization = `Bearer ${githubToken}`;
48
- const response = await (options.fetchImpl ?? fetch)(url, { headers });
49
- if (!response.ok) {
50
- throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
51
- }
52
- const body = await response.json();
53
- if (!Array.isArray(body)) {
54
- throw new Error("GitHub pull request lookup returned a non-array response");
55
- }
76
+ const fetchImpl = options.fetchImpl ?? fetch;
56
77
  const normalizedRepository = options.repository.toLowerCase();
57
- for (const candidate of body) {
58
- if (candidate.head?.ref !== options.headBranch)
59
- continue;
60
- const headRepository = candidate.head.repo?.full_name;
61
- if (typeof headRepository !== "string")
62
- continue;
63
- if (headRepository.toLowerCase() !== normalizedRepository)
64
- continue;
65
- if (typeof candidate.html_url !== "string")
66
- continue;
67
- if (!isPullRequestUrlForRepository(candidate.html_url, normalizedRepository))
68
- continue;
69
- return candidate.html_url;
78
+ const normalizedHeadSha = options.headSha?.toLowerCase();
79
+ if (normalizedHeadSha && !/^[0-9a-f]{40,64}$/.test(normalizedHeadSha)) {
80
+ throw new Error(`Invalid Git head SHA: ${options.headSha}`);
70
81
  }
71
- return null;
82
+ const branchMatch = matchingPullRequestUrl(await fetchPullRequests(branchUrl, headers, fetchImpl), normalizedRepository, (candidate) => candidate.head?.ref === options.headBranch &&
83
+ (!normalizedHeadSha ||
84
+ (typeof candidate.head.sha === "string" &&
85
+ candidate.head.sha.toLowerCase() === normalizedHeadSha)));
86
+ if (branchMatch || !normalizedHeadSha)
87
+ return branchMatch;
88
+ const commitUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${normalizedHeadSha}/pulls`);
89
+ commitUrl.searchParams.set("per_page", "10");
90
+ return matchingPullRequestUrl(await fetchPullRequests(commitUrl, headers, fetchImpl), normalizedRepository, (candidate) => typeof candidate.head?.sha === "string" &&
91
+ candidate.head.sha.toLowerCase() === normalizedHeadSha);
72
92
  }
73
93
  function isPullRequestUrlForRepository(value, repository) {
74
94
  try {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MachineHeartbeatLoop = void 0;
4
4
  const version_cjs_1 = require("./version.cjs");
5
+ const usage_limits_cjs_1 = require("./usage-limits.cjs");
5
6
  const logger_cjs_1 = require("./logger.cjs");
6
7
  const log = (0, logger_cjs_1.createLogger)("heartbeat");
7
8
  /**
@@ -19,6 +20,8 @@ class MachineHeartbeatLoop {
19
20
  onResult;
20
21
  onHealthy;
21
22
  timer = null;
23
+ heartbeatInFlight = false;
24
+ heartbeatPending = false;
22
25
  updateState = "idle";
23
26
  lastUpdateError;
24
27
  draining = false;
@@ -41,6 +44,7 @@ class MachineHeartbeatLoop {
41
44
  if (this.timer)
42
45
  clearInterval(this.timer);
43
46
  this.timer = null;
47
+ this.heartbeatPending = false;
44
48
  }
45
49
  setUpdateState(state, error) {
46
50
  this.updateState = state;
@@ -49,24 +53,48 @@ class MachineHeartbeatLoop {
49
53
  void this.tick();
50
54
  }
51
55
  async tick() {
56
+ if (this.heartbeatInFlight) {
57
+ this.heartbeatPending = true;
58
+ return;
59
+ }
60
+ this.heartbeatInFlight = true;
52
61
  try {
53
- const result = await this.api.machineHeartbeat(this.machineId, {
54
- available_capacity: this.draining ? 0 : this.capacity.available(),
55
- capabilities: this.config.capabilities,
56
- runtime: {
57
- version: version_cjs_1.RUNTIME_VERSION,
58
- updater_protocol: version_cjs_1.UPDATER_PROTOCOL_VERSION,
59
- channel: this.config.updateChannel,
60
- state: this.updateState,
61
- boot_id: this.bootId,
62
- ...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
63
- },
64
- });
65
- this.onHealthy?.();
66
- this.onResult?.(result);
62
+ do {
63
+ this.heartbeatPending = false;
64
+ try {
65
+ // Always sent when collection is enabled, including as an empty array:
66
+ // that is how the control plane learns a previously reported quota is
67
+ // gone (agent uninstalled, telemetry disabled) instead of pinning a
68
+ // reading that no longer exists.
69
+ const agentUsage = this.config.reportUsageLimits
70
+ ? await (0, usage_limits_cjs_1.collectAgentUsage)({
71
+ codexHome: this.config.codexHome,
72
+ claudeConfigPath: this.config.claudeConfigPath,
73
+ })
74
+ : null;
75
+ const result = await this.api.machineHeartbeat(this.machineId, {
76
+ available_capacity: this.draining ? 0 : this.capacity.available(),
77
+ capabilities: this.config.capabilities,
78
+ ...(agentUsage ? { agent_usage: agentUsage } : {}),
79
+ runtime: {
80
+ version: version_cjs_1.RUNTIME_VERSION,
81
+ updater_protocol: version_cjs_1.UPDATER_PROTOCOL_VERSION,
82
+ channel: this.config.updateChannel,
83
+ state: this.updateState,
84
+ boot_id: this.bootId,
85
+ ...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
86
+ },
87
+ });
88
+ this.onHealthy?.();
89
+ this.onResult?.(result);
90
+ }
91
+ catch (err) {
92
+ log.warn(`machine heartbeat failed: ${String(err)}`);
93
+ }
94
+ } while (this.heartbeatPending);
67
95
  }
68
- catch (err) {
69
- log.warn(`machine heartbeat failed: ${String(err)}`);
96
+ finally {
97
+ this.heartbeatInFlight = false;
70
98
  }
71
99
  }
72
100
  }
package/dist/session.cjs CHANGED
@@ -95,6 +95,7 @@ async function runSession(deps, claimed, sessionId) {
95
95
  leaseId,
96
96
  };
97
97
  }
98
+ const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail);
98
99
  const cloneUrl = bundle.repository?.clone_url ??
99
100
  (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
100
101
  if (!cloneUrl) {
@@ -241,6 +242,9 @@ async function runSession(deps, claimed, sessionId) {
241
242
  }
242
243
  }
243
244
  else if (runtime === "codex") {
245
+ const ghConfigDir = (0, worktree_guard_cjs_1.codexGhConfigDir)(workDir);
246
+ await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
247
+ sessionEnv.GH_CONFIG_DIR = ghConfigDir;
244
248
  codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
245
249
  workDir,
246
250
  worktreePath: gitWorktree.worktreePath,
@@ -259,14 +263,17 @@ async function runSession(deps, claimed, sessionId) {
259
263
  });
260
264
  }
261
265
  }
266
+ // Kept outside the try so the crash path can report the telemetry this
267
+ // session actually produced instead of zeroing it.
268
+ let lastCompletion = null;
262
269
  try {
263
270
  await gitWorktree.prepare();
264
271
  if (sandbox) {
265
272
  await sandbox.create();
266
- await sandbox.injectEnv(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
273
+ await sandbox.injectEnv(sessionEnv);
267
274
  }
268
- // The control plane resolves the project default/task override before
269
- // claim and returns one of this worker's advertised runtimes.
275
+ // The control plane returns the agent type selected for this worker.
276
+ // Any agent type may run any task; capabilities remain the task-level gate.
270
277
  // All adapters implement the same AgentAdapter.run() shape
271
278
  // (adapters/types.cts), so nothing else in this function branches on
272
279
  // which agent is running.
@@ -297,7 +304,7 @@ async function runSession(deps, claimed, sessionId) {
297
304
  model: execution.model,
298
305
  reasoningEffort: execution.reasoning_effort,
299
306
  timeoutMs: config.sessionTimeoutMs,
300
- env: toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail),
307
+ env: sessionEnv,
301
308
  settingsPath: claudeSettingsPath,
302
309
  codexGuardArgs,
303
310
  geminiSandboxMounts: geminiGuardMounts,
@@ -334,10 +341,22 @@ async function runSession(deps, claimed, sessionId) {
334
341
  ...result,
335
342
  killedByLeaseLoss: leaseLost || result.killedByLeaseLoss,
336
343
  });
344
+ let headBranch = gitWorktree.branch;
345
+ let headSha;
346
+ try {
347
+ const currentHead = await gitWorktree.resolveCurrentHead();
348
+ headBranch = currentHead.branch ?? headBranch;
349
+ headSha = currentHead.sha;
350
+ }
351
+ catch (err) {
352
+ // Preserve the original exact-branch lookup if HEAD cannot be read.
353
+ log.warn(`worktree HEAD lookup failed for ${leaseId}: ${String(err)}`);
354
+ }
337
355
  try {
338
356
  const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
339
357
  repository: bundle.repository?.full_name ?? task.repo,
340
- headBranch: gitWorktree.branch,
358
+ headBranch,
359
+ headSha,
341
360
  githubToken,
342
361
  });
343
362
  if (prUrl)
@@ -369,7 +388,9 @@ async function runSession(deps, claimed, sessionId) {
369
388
  transcriptUrl = public_url;
370
389
  }
371
390
  catch (err) {
372
- log.warn(`transcript upload failed for ${leaseId}: ${String(err)}`);
391
+ // The message carries up to 500 chars of the storage response body, so
392
+ // redact it like every other warn in this block.
393
+ log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
373
394
  }
374
395
  const completion = {
375
396
  status: mapping.leaseOutcome,
@@ -385,6 +406,12 @@ async function runSession(deps, claimed, sessionId) {
385
406
  agent_type: runtime,
386
407
  ...executionReport,
387
408
  };
409
+ lastCompletion = {
410
+ report: completion.report,
411
+ evidence_urls: completion.evidence_urls,
412
+ cost: completion.cost,
413
+ transcript_url: completion.transcript_url,
414
+ };
388
415
  try {
389
416
  await api.completeLease(leaseId, completion);
390
417
  break;
@@ -394,38 +421,73 @@ async function runSession(deps, claimed, sessionId) {
394
421
  log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
395
422
  return sessionOutcome;
396
423
  }
397
- const rejection = mapping.leaseOutcome === "completed" ? completionRemediationMessage(err) : null;
424
+ const rejection = completionRejection(err);
398
425
  if (!rejection)
399
426
  throw err;
400
- const redactedRejection = (0, redact_cjs_1.redactText)(rejection, knownSecrets);
401
- if (completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
427
+ const redactedRejection = (0, redact_cjs_1.redactText)(rejection.message, knownSecrets);
428
+ const remediable = mapping.leaseOutcome === "completed" && rejection.remediable;
429
+ if (remediable && completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
402
430
  completionRemediationRetries += 1;
403
431
  log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
404
432
  nextPrompt = redactedRejection;
405
433
  continue;
406
434
  }
407
- log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
408
- await api.completeLease(leaseId, {
409
- ...completion,
410
- status: "failed",
411
- report: redactedRejection,
412
- failure_summary: redactedRejection,
413
- exit_status: "failed",
414
- });
435
+ if (remediable) {
436
+ log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
437
+ }
438
+ else {
439
+ // Not something another turn can fix (e.g. review_evidence_tampered).
440
+ // Fail the lease once, keeping this attempt's real report, cost and
441
+ // evidence — this is a rejected attempt, not a crashed session.
442
+ log.error(`completion for ${leaseId} was rejected as ${rejection.code}, which is not remediable; failing the lease.`);
443
+ }
444
+ try {
445
+ await api.completeLease(leaseId, {
446
+ ...completion,
447
+ status: "failed",
448
+ report: remediable
449
+ ? redactedRejection
450
+ : `${redactedRejection}\n\n---\n\n${completion.report}`,
451
+ failure_summary: redactedRejection,
452
+ exit_status: "failed",
453
+ });
454
+ }
455
+ catch (fallbackErr) {
456
+ // Same race as the primary completion above: if something else released
457
+ // the lease first, this is a clean exit, not a crash. Letting it escape
458
+ // would post a second `Session crashed:` completion with zeroed cost.
459
+ if (!isAlreadyReleasedCompletionError(fallbackErr))
460
+ throw fallbackErr;
461
+ log.warn(`fallback completion skipped for ${leaseId}: the lease was already released.`);
462
+ return sessionOutcome;
463
+ }
415
464
  break;
416
465
  }
417
466
  }
418
467
  }
419
468
  catch (err) {
420
- log.error(`session ${leaseId} threw before completing: ${String(err)}`);
421
- const failureSummary = (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, registry.list());
469
+ const knownSecrets = registry.list();
470
+ // NavarchApiError's message is only the status line; the control plane's
471
+ // explanation rides in `err.body`. Keep it in the agent log (redacted like
472
+ // every other body we surface) so a 4xx/5xx completion stays diagnosable.
473
+ const crashDetail = (0, redact_cjs_1.redactText)(describeCompletionError(err), knownSecrets);
474
+ log.error(`session ${leaseId} threw before completing: ${crashDetail}`);
475
+ // Send the same bounded, redacted detail to the control plane that we log
476
+ // locally. NavarchApiError.message contains only the HTTP status line; its
477
+ // response body carries the actionable server error shown in session UI.
478
+ const failureSummary = `Session crashed: ${crashDetail}`;
422
479
  await api
423
480
  .completeLease(leaseId, {
424
481
  status: "failed",
425
- report: failureSummary,
482
+ report: lastCompletion
483
+ ? `${lastCompletion.report}\n\n---\n\n${failureSummary}`
484
+ : failureSummary,
426
485
  failure_summary: failureSummary,
427
- evidence_urls: [],
428
- cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
486
+ evidence_urls: lastCompletion?.evidence_urls ?? [],
487
+ cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
488
+ ...(lastCompletion?.transcript_url
489
+ ? { transcript_url: lastCompletion.transcript_url }
490
+ : {}),
429
491
  exit_status: "crashed",
430
492
  agent_type: runtime,
431
493
  ...executionReport,
@@ -460,27 +522,100 @@ function sumReportedUsage(attempts, key) {
460
522
  });
461
523
  return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
462
524
  }
463
- function completionRemediationMessage(err) {
525
+ /** Rejection codes another agent turn in the same worktree can plausibly fix. */
526
+ const REMEDIABLE_REJECTION_CODES = new Set([
527
+ "pr_required",
528
+ "pr_not_ready",
529
+ "review_attestation_required",
530
+ "review_attestation_invalid",
531
+ "review_head_changed",
532
+ "review_evidence_missing",
533
+ // The marker is already on the right head; the reviewer only has to relabel
534
+ // the body, which is exactly what a remediation turn can do.
535
+ "review_evidence_mislabeled",
536
+ ]);
537
+ /**
538
+ * A 409 the control plane raised to reject *this* completion's contents (as
539
+ * opposed to a lease that is simply gone). Non-remediable codes still describe
540
+ * a failed attempt, not a crashed session, so the caller fails the lease once
541
+ * rather than routing the error through the crash path.
542
+ */
543
+ function completionRejection(err) {
464
544
  if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
465
545
  return null;
466
- if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
467
- return null;
468
- const body = err.body;
469
- return (body.code === "pr_required" || body.code === "pr_not_ready") &&
470
- typeof body.error === "string"
471
- ? body.error
472
- : null;
546
+ if (typeof err.body === "object" && err.body !== null && !Array.isArray(err.body)) {
547
+ const body = err.body;
548
+ if (typeof body.code === "string" && typeof body.error === "string") {
549
+ return {
550
+ code: body.code,
551
+ message: body.error,
552
+ remediable: REMEDIABLE_REJECTION_CODES.has(body.code),
553
+ };
554
+ }
555
+ }
556
+ // A 409 whose body the runtime cannot interpret — an edge/proxy error page,
557
+ // an HTML body, a truncated response (api.cts hands the raw text through when
558
+ // safeJsonParse fails). The lease is still rejected, so fail it once with this
559
+ // attempt's real report/cost/evidence rather than crashing the session and
560
+ // posting a zeroed-cost `Session crashed:` completion. Callers check
561
+ // isAlreadyReleasedCompletionError first, so the benign race never lands here.
562
+ return {
563
+ code: UNRECOGNIZED_REJECTION_CODE,
564
+ message: "The control plane rejected this completion with HTTP 409, but the response body " +
565
+ `was not a recognizable rejection: ${describeResponseBody(err.body)}`,
566
+ remediable: false,
567
+ };
568
+ }
569
+ /** Synthetic code for a 409 that carried no machine-readable `{error, code}` pair. */
570
+ const UNRECOGNIZED_REJECTION_CODE = "unrecognized_completion_rejection";
571
+ const MAX_DESCRIBED_BODY_CHARS = 500;
572
+ /** Collapses an arbitrary error-response body into one bounded, loggable line. */
573
+ function describeResponseBody(body) {
574
+ let text;
575
+ if (body === null || body === undefined) {
576
+ text = "";
577
+ }
578
+ else if (typeof body === "string") {
579
+ text = body;
580
+ }
581
+ else {
582
+ try {
583
+ text = JSON.stringify(body) ?? String(body);
584
+ }
585
+ catch {
586
+ text = String(body);
587
+ }
588
+ }
589
+ const collapsed = text.replace(/\s+/g, " ").trim();
590
+ if (collapsed.length === 0)
591
+ return "<empty>";
592
+ return collapsed.length > MAX_DESCRIBED_BODY_CHARS
593
+ ? `${collapsed.slice(0, MAX_DESCRIBED_BODY_CHARS)}…`
594
+ : collapsed;
595
+ }
596
+ /** Error text for the crash log, including a NavarchApiError's response body. */
597
+ function describeCompletionError(err) {
598
+ if (!(err instanceof api_cjs_1.NavarchApiError))
599
+ return String(err);
600
+ return `${String(err)} (body: ${describeResponseBody(err.body)})`;
473
601
  }
474
602
  function isTerminalLeaseHeartbeatError(err) {
475
603
  return err instanceof api_cjs_1.NavarchApiError && [403, 404, 410].includes(err.status);
476
604
  }
605
+ /**
606
+ * A 409 that means "someone already released this lease" — a benign race, not
607
+ * work to remediate. The control plane tags it `code: "already_released"`
608
+ * (app/api/dispatch/[leaseId]/complete/route.ts); the prose form is what
609
+ * control planes before that shipped, and is kept so a new runtime still
610
+ * recognizes an older deployment.
611
+ */
477
612
  function isAlreadyReleasedCompletionError(err) {
478
613
  if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
479
614
  return false;
480
615
  if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
481
616
  return false;
482
617
  const body = err.body;
483
- return body.error === "lease already released";
618
+ return body.code === "already_released" || body.error === "lease already released";
484
619
  }
485
620
  /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
486
621
  function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
package/dist/upload.cjs CHANGED
@@ -7,6 +7,8 @@ exports.uploadTranscript = uploadTranscript;
7
7
  * implementation-plan.md WP-07). Kept as its own module so the storage
8
8
  * mechanism is a one-function swap if it ever changes.
9
9
  */
10
+ /** Storage error bodies are small JSON blobs; cap in case we get an HTML page. */
11
+ const MAX_ERROR_BODY = 500;
10
12
  async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
11
13
  const response = await fetchImpl(uploadUrl, {
12
14
  method: "PUT",
@@ -14,6 +16,21 @@ async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
14
16
  body: content,
15
17
  });
16
18
  if (!response.ok) {
17
- throw new Error(`Transcript upload failed with ${response.status}`);
19
+ // Supabase Storage answers with {statusCode, error, message} here, which is
20
+ // the only thing distinguishing an RLS rejection from a bad/expired token
21
+ // from a bucket misconfiguration. Without it a 403 is unattributable.
22
+ throw new Error(`Transcript upload failed with ${response.status}: ${await readErrorBody(response)}`);
18
23
  }
19
24
  }
25
+ async function readErrorBody(response) {
26
+ let body;
27
+ try {
28
+ body = (await response.text()).trim();
29
+ }
30
+ catch (err) {
31
+ return `<unreadable response body: ${String(err)}>`;
32
+ }
33
+ if (!body)
34
+ return "<empty response body>";
35
+ return body.length > MAX_ERROR_BODY ? `${body.slice(0, MAX_ERROR_BODY)}…` : body;
36
+ }
@@ -0,0 +1,400 @@
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.classifyWindow = classifyWindow;
7
+ exports.parseCodexRateLimitSnapshot = parseCodexRateLimitSnapshot;
8
+ exports.findLatestCodexSnapshot = findLatestCodexSnapshot;
9
+ exports.collectCodexUsage = collectCodexUsage;
10
+ exports.parseClaudeUsageCache = parseClaudeUsageCache;
11
+ exports.collectClaudeUsage = collectClaudeUsage;
12
+ exports.collectAgentUsage = collectAgentUsage;
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const logger_cjs_1 = require("./logger.cjs");
16
+ const log = (0, logger_cjs_1.createLogger)("usage-limits");
17
+ /**
18
+ * Reads the usage-limit telemetry that coding agents already write to disk on
19
+ * this machine and normalizes it for the machine heartbeat (see
20
+ * heartbeat-loop.cts).
21
+ *
22
+ * Ground rules, because this reads files an agent wrote for its own use:
23
+ *
24
+ * - Only provider-emitted quota numbers are collected. Credentials, prompts,
25
+ * transcripts and token counts that sit next to them in the same files are
26
+ * never read out, and the report is rebuilt field by field rather than
27
+ * forwarded, so nothing unrecognized can ride along.
28
+ * - Every field is optional and version-sensitive. A value we cannot verify is
29
+ * dropped, never guessed: a missing window is reported as absent so the UI
30
+ * can say "not reported" instead of inventing a quota.
31
+ * - Collection is best-effort and must never break a heartbeat. Every failure
32
+ * path returns an empty report.
33
+ */
34
+ /** Cap on windows kept per provider — Codex reports 2, Claude Code 2. */
35
+ const MAX_WINDOWS_PER_PROVIDER = 6;
36
+ /** Tail of a rollout file we are willing to read; snapshots are appended. */
37
+ const ROLLOUT_TAIL_BYTES = 256 * 1024;
38
+ /** Newest rollout files to scan before giving up on finding a snapshot. */
39
+ const MAX_ROLLOUT_CANDIDATES = 5;
40
+ /** Ceiling on rollout paths collected while walking the sessions tree. */
41
+ const MAX_ROLLOUT_SCAN = 200;
42
+ /** Largest `.claude.json` we will parse. The file also holds unrelated state. */
43
+ const MAX_CLAUDE_CONFIG_BYTES = 8 * 1024 * 1024;
44
+ /**
45
+ * Window durations Codex's own TUI recognizes, with the same ±5% tolerance it
46
+ * uses (codex-rs/tui/src/chatwidget/rate_limits.rs). Neither provider promises
47
+ * that `primary` is the short window, so the label is always derived from the
48
+ * reported duration rather than the slot it arrived in.
49
+ */
50
+ const WINDOW_KINDS = [
51
+ [300, "5h"],
52
+ [1440, "daily"],
53
+ [10080, "weekly"],
54
+ [43200, "monthly"],
55
+ [525600, "annual"],
56
+ ];
57
+ function classifyWindow(windowMinutes) {
58
+ if (windowMinutes === null)
59
+ return "unknown";
60
+ for (const [expected, kind] of WINDOW_KINDS) {
61
+ if (windowMinutes >= expected * 0.95 && windowMinutes <= expected * 1.05)
62
+ return kind;
63
+ }
64
+ return "unknown";
65
+ }
66
+ /**
67
+ * Percentages are contract-bounded to 0-100. An out-of-range number means we
68
+ * misread the provider's scale, so the window is dropped rather than clamped —
69
+ * clamping 4200 to 100 would render as a confident "quota exhausted".
70
+ */
71
+ function normalizeUsedPercent(value) {
72
+ if (typeof value !== "number" || !Number.isFinite(value))
73
+ return null;
74
+ if (value < 0 || value > 100)
75
+ return null;
76
+ return Math.round(value * 10) / 10;
77
+ }
78
+ /** Positive whole minutes, capped at ten years to reject nonsense durations. */
79
+ function normalizeWindowMinutes(value) {
80
+ if (typeof value !== "number" || !Number.isInteger(value))
81
+ return null;
82
+ if (value <= 0 || value > 5_256_000)
83
+ return null;
84
+ return value;
85
+ }
86
+ /** Rejects reset instants outside [2020, now + 2y] as unparseable rather than real. */
87
+ function isoFromEpochMs(ms, nowMs) {
88
+ if (!Number.isFinite(ms))
89
+ return null;
90
+ if (ms < Date.UTC(2020, 0, 1))
91
+ return null;
92
+ if (ms > nowMs + 2 * 365 * 24 * 60 * 60 * 1000)
93
+ return null;
94
+ return new Date(ms).toISOString();
95
+ }
96
+ function isoFromEpochSeconds(value, nowMs) {
97
+ if (typeof value !== "number" || !Number.isFinite(value))
98
+ return null;
99
+ return isoFromEpochMs(Math.round(value * 1000), nowMs);
100
+ }
101
+ /** Codex ≤ v0.47 reported a duration relative to the emitting event. */
102
+ function isoFromRelativeSeconds(value, observedAtMs, nowMs) {
103
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
104
+ return null;
105
+ return isoFromEpochMs(observedAtMs + Math.round(value * 1000), nowMs);
106
+ }
107
+ function isoFromString(value, nowMs) {
108
+ if (typeof value !== "string" || !value.trim())
109
+ return null;
110
+ const parsed = Date.parse(value);
111
+ return Number.isNaN(parsed) ? null : isoFromEpochMs(parsed, nowMs);
112
+ }
113
+ function buildWindow(usedPercent, windowMinutes, resetsAt) {
114
+ if (usedPercent === null)
115
+ return null;
116
+ return {
117
+ kind: classifyWindow(windowMinutes),
118
+ window_minutes: windowMinutes,
119
+ used_percent: usedPercent,
120
+ resets_at: resetsAt,
121
+ };
122
+ }
123
+ // --------------------------------------------------------------------------
124
+ // Codex — session rollout JSONL
125
+ // --------------------------------------------------------------------------
126
+ /**
127
+ * Parses one Codex `RateLimitSnapshot`. Field names are stable across the
128
+ * versions we support (`used_percent`, `window_minutes`); `resets_at` (epoch
129
+ * seconds) replaced `resets_in_seconds` in Codex v0.48, so both are accepted.
130
+ * Everything else in the snapshot — plan type, credit balance, spend controls —
131
+ * is deliberately ignored.
132
+ */
133
+ function parseCodexRateLimitSnapshot(snapshot, observedAtMs, nowMs) {
134
+ if (!snapshot || typeof snapshot !== "object")
135
+ return [];
136
+ const raw = snapshot;
137
+ const windows = [];
138
+ for (const slot of ["primary", "secondary"]) {
139
+ const value = raw[slot];
140
+ if (!value || typeof value !== "object")
141
+ continue;
142
+ const win = value;
143
+ const parsed = buildWindow(normalizeUsedPercent(win.used_percent), normalizeWindowMinutes(win.window_minutes), isoFromEpochSeconds(win.resets_at, nowMs) ??
144
+ isoFromRelativeSeconds(win.resets_in_seconds, observedAtMs, nowMs));
145
+ if (parsed)
146
+ windows.push(parsed);
147
+ }
148
+ return windows.slice(0, MAX_WINDOWS_PER_PROVIDER);
149
+ }
150
+ /**
151
+ * Scans a rollout JSONL tail backwards for the newest `token_count` event that
152
+ * carries a rate-limit snapshot. Codex appends these throughout a session, so
153
+ * the last one is the freshest quota reading this machine has seen.
154
+ */
155
+ function findLatestCodexSnapshot(jsonl) {
156
+ const lines = jsonl.split("\n");
157
+ for (let i = lines.length - 1; i >= 0; i--) {
158
+ const line = lines[i]?.trim() ?? "";
159
+ // Cheap reject before paying for JSON.parse on every transcript line.
160
+ if (!line || !line.includes("\"token_count\"") || !line.includes("\"rate_limits\""))
161
+ continue;
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(line);
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ if (!parsed || typeof parsed !== "object")
170
+ continue;
171
+ const entry = parsed;
172
+ if (entry.type !== "event_msg")
173
+ continue;
174
+ const payload = entry.payload;
175
+ if (!payload || typeof payload !== "object")
176
+ continue;
177
+ const event = payload;
178
+ if (event.type !== "token_count")
179
+ continue;
180
+ if (!event.rate_limits || typeof event.rate_limits !== "object")
181
+ continue;
182
+ const timestamp = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
183
+ return {
184
+ snapshot: event.rate_limits,
185
+ observedAtMs: Number.isNaN(timestamp) ? null : timestamp,
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+ async function listSortedDirs(dir, limit) {
191
+ const entries = await node_fs_1.promises.readdir(dir, { withFileTypes: true }).catch(() => []);
192
+ return entries
193
+ .filter((entry) => entry.isDirectory())
194
+ .map((entry) => entry.name)
195
+ .sort()
196
+ .reverse()
197
+ .slice(0, limit);
198
+ }
199
+ /**
200
+ * Collects rollout paths from the newest `sessions/YYYY/MM/DD` directories.
201
+ * Names are zero-padded, so lexical order is chronological order; two branches
202
+ * per level covers a month or year boundary crossed mid-session.
203
+ */
204
+ async function recentRolloutFiles(sessionsDir) {
205
+ const files = [];
206
+ for (const year of await listSortedDirs(sessionsDir, 2)) {
207
+ for (const month of await listSortedDirs(node_path_1.default.join(sessionsDir, year), 2)) {
208
+ for (const day of await listSortedDirs(node_path_1.default.join(sessionsDir, year, month), 2)) {
209
+ const dayDir = node_path_1.default.join(sessionsDir, year, month, day);
210
+ const entries = await node_fs_1.promises.readdir(dayDir, { withFileTypes: true }).catch(() => []);
211
+ for (const entry of entries) {
212
+ // Cold rollouts are rewritten as `.jsonl.zst`; the newest session —
213
+ // the only one with a current quota reading — is never compressed.
214
+ if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
215
+ files.push(node_path_1.default.join(dayDir, entry.name));
216
+ }
217
+ }
218
+ if (files.length >= MAX_ROLLOUT_SCAN)
219
+ return files;
220
+ }
221
+ }
222
+ }
223
+ return files;
224
+ }
225
+ /** Reads the last `maxBytes` of a file, discarding a leading partial line. */
226
+ async function readTail(file, maxBytes) {
227
+ const handle = await node_fs_1.promises.open(file, "r");
228
+ try {
229
+ const { size } = await handle.stat();
230
+ const start = size > maxBytes ? size - maxBytes : 0;
231
+ const length = size - start;
232
+ if (length <= 0)
233
+ return "";
234
+ const buffer = Buffer.alloc(length);
235
+ await handle.read(buffer, 0, length, start);
236
+ const text = buffer.toString("utf8");
237
+ if (start === 0)
238
+ return text;
239
+ const firstBreak = text.indexOf("\n");
240
+ return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
241
+ }
242
+ finally {
243
+ await handle.close();
244
+ }
245
+ }
246
+ async function collectCodexUsage(options) {
247
+ const nowMs = (options.now ?? Date.now)();
248
+ const sessionsDir = node_path_1.default.join(options.codexHome, "sessions");
249
+ const candidates = await recentRolloutFiles(sessionsDir);
250
+ if (candidates.length === 0)
251
+ return null;
252
+ const stated = await Promise.all(candidates.map(async (file) => {
253
+ const stat = await node_fs_1.promises.stat(file).catch(() => null);
254
+ return stat ? { file, mtimeMs: stat.mtimeMs } : null;
255
+ }));
256
+ const newest = stated
257
+ .filter((entry) => entry !== null)
258
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
259
+ .slice(0, MAX_ROLLOUT_CANDIDATES);
260
+ for (const { file, mtimeMs } of newest) {
261
+ const tail = await readTail(file, ROLLOUT_TAIL_BYTES).catch(() => "");
262
+ if (!tail)
263
+ continue;
264
+ const found = findLatestCodexSnapshot(tail);
265
+ if (!found)
266
+ continue;
267
+ const observedAtMs = found.observedAtMs ?? mtimeMs;
268
+ const windows = parseCodexRateLimitSnapshot(found.snapshot, observedAtMs, nowMs);
269
+ if (windows.length === 0)
270
+ continue;
271
+ const observedAt = isoFromEpochMs(observedAtMs, nowMs);
272
+ if (!observedAt)
273
+ continue;
274
+ return { provider: "codex", source: "codex-rollout", observed_at: observedAt, windows };
275
+ }
276
+ return null;
277
+ }
278
+ // --------------------------------------------------------------------------
279
+ // Claude Code — cached utilization in the global config
280
+ // --------------------------------------------------------------------------
281
+ /**
282
+ * Claude Code's five-hour and weekly windows reach disk only through
283
+ * `cachedUsageUtilization` in its global config, which it refreshes when a
284
+ * human runs `/usage` on this machine. Headless `claude -p` sessions — the ones
285
+ * the runtime dispatches — never write it, so an absent or stale reading is the
286
+ * normal case and is reported as such rather than back-filled from token counts.
287
+ *
288
+ * On this path `utilization` is a percentage (0-100) and `resets_at` an ISO
289
+ * string. Claude Code keeps a *second*, header-derived store in different units
290
+ * — a 0-1 fraction with an epoch-seconds reset — which it exposes to statusline
291
+ * scripts. Reading one as the other silently misreports usage by 100×, so a
292
+ * numeric `resets_at` is taken as proof we are looking at the wrong shape and
293
+ * the window is discarded rather than converted on a guess.
294
+ */
295
+ function parseClaudeUsageCache(value, nowMs) {
296
+ if (!value || typeof value !== "object")
297
+ return null;
298
+ const cache = value;
299
+ const fetchedAtMs = cache.fetchedAtMs;
300
+ if (typeof fetchedAtMs !== "number" || !Number.isFinite(fetchedAtMs))
301
+ return null;
302
+ // A reading from the future is a broken clock, not a fresh observation.
303
+ if (fetchedAtMs > nowMs + 5 * 60 * 1000)
304
+ return null;
305
+ if (!isoFromEpochMs(fetchedAtMs, nowMs))
306
+ return null;
307
+ const utilization = cache.utilization;
308
+ if (!utilization || typeof utilization !== "object")
309
+ return null;
310
+ const byWindow = utilization;
311
+ const windows = [];
312
+ for (const [key, windowMinutes] of [
313
+ ["five_hour", 300],
314
+ ["seven_day", 10080],
315
+ ]) {
316
+ const entry = byWindow[key];
317
+ if (!entry || typeof entry !== "object")
318
+ continue;
319
+ const raw = entry;
320
+ if (typeof raw.resets_at === "number")
321
+ continue;
322
+ const parsed = buildWindow(normalizeUsedPercent(raw.utilization), windowMinutes, isoFromString(raw.resets_at, nowMs));
323
+ if (parsed)
324
+ windows.push(parsed);
325
+ }
326
+ if (windows.length === 0)
327
+ return null;
328
+ return { observedAtMs: fetchedAtMs, windows: windows.slice(0, MAX_WINDOWS_PER_PROVIDER) };
329
+ }
330
+ async function collectClaudeUsage(options) {
331
+ const nowMs = (options.now ?? Date.now)();
332
+ const stat = await node_fs_1.promises.stat(options.configPath).catch(() => null);
333
+ if (!stat || !stat.isFile() || stat.size > MAX_CLAUDE_CONFIG_BYTES)
334
+ return null;
335
+ const raw = await node_fs_1.promises.readFile(options.configPath, "utf8").catch(() => null);
336
+ if (!raw)
337
+ return null;
338
+ let parsed;
339
+ try {
340
+ parsed = JSON.parse(raw);
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ if (!parsed || typeof parsed !== "object")
346
+ return null;
347
+ const found = parseClaudeUsageCache(parsed.cachedUsageUtilization, nowMs);
348
+ if (!found)
349
+ return null;
350
+ const observedAt = isoFromEpochMs(found.observedAtMs, nowMs);
351
+ if (!observedAt)
352
+ return null;
353
+ return {
354
+ provider: "claude-code",
355
+ source: "claude-usage-cache",
356
+ observed_at: observedAt,
357
+ windows: found.windows,
358
+ };
359
+ }
360
+ const DEFAULT_COLLECT_TIMEOUT_MS = 5_000;
361
+ /**
362
+ * Gathers every provider report available on this machine. Providers are read
363
+ * independently so one unreadable or malformed source cannot suppress another,
364
+ * and any thrown error degrades to "no report" rather than failing the caller.
365
+ */
366
+ async function collectAgentUsage(options) {
367
+ const timeoutMs = options.timeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS;
368
+ let timer;
369
+ const deadline = new Promise((resolve) => {
370
+ timer = setTimeout(() => resolve(null), timeoutMs);
371
+ // Never hold the process open on account of usage collection.
372
+ timer.unref?.();
373
+ });
374
+ try {
375
+ const collection = Promise.allSettled([
376
+ collectCodexUsage({ codexHome: options.codexHome, now: options.now }),
377
+ collectClaudeUsage({ configPath: options.claudeConfigPath, now: options.now }),
378
+ ]);
379
+ const settled = await Promise.race([collection, deadline]);
380
+ if (!settled) {
381
+ log.warn(`usage collection timed out after ${timeoutMs}ms`);
382
+ return [];
383
+ }
384
+ const reports = [];
385
+ for (const result of settled) {
386
+ if (result.status === "fulfilled") {
387
+ if (result.value)
388
+ reports.push(result.value);
389
+ }
390
+ else {
391
+ log.warn(`usage collection failed: ${String(result.reason)}`);
392
+ }
393
+ }
394
+ return reports;
395
+ }
396
+ finally {
397
+ if (timer)
398
+ clearTimeout(timer);
399
+ }
400
+ }
@@ -7,12 +7,18 @@ exports.guardHookScriptPath = guardHookScriptPath;
7
7
  exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
8
8
  exports.prepareWorktreeGuard = prepareWorktreeGuard;
9
9
  exports.codexToolReadRoots = codexToolReadRoots;
10
+ exports.codexGhConfigDir = codexGhConfigDir;
10
11
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
11
12
  exports.geminiSandboxMounts = geminiSandboxMounts;
12
13
  const node_path_1 = __importDefault(require("node:path"));
13
14
  const node_fs_1 = require("node:fs");
14
15
  const node_os_1 = __importDefault(require("node:os"));
15
16
  const CODEX_GUARD_PROFILE = "navarch-worktree";
17
+ const CODEX_GH_CONFIG_DIRNAME = "gh-config";
18
+ const CODEX_GITHUB_NETWORK_DOMAINS = {
19
+ "**.github.com": "allow",
20
+ "**.githubusercontent.com": "allow",
21
+ };
16
22
  const CLAUDE_USER_SETTINGS_FILENAME = "settings.json";
17
23
  /**
18
24
  * Tools the hook screens. Everything else — the lease-scoped Navarch MCP
@@ -122,6 +128,17 @@ function codexToolReadRoots(env = process.env) {
122
128
  node_path_1.default.join(homeDir, ".cache", "ms-playwright"),
123
129
  ];
124
130
  }
131
+ /**
132
+ * Isolated gh configuration root for one Codex session.
133
+ *
134
+ * `gh` reads its config file before it considers `GITHUB_TOKEN`. Pointing it
135
+ * at this empty, session-owned directory lets the CLI use the lease token
136
+ * without granting the agent read access to the operator's ~/.config/gh,
137
+ * which may contain unrelated account credentials.
138
+ */
139
+ function codexGhConfigDir(workDir) {
140
+ return node_path_1.default.join(workDir, CODEX_GH_CONFIG_DIRNAME);
141
+ }
125
142
  /**
126
143
  * Builds one-off Codex permission-profile arguments for a host session.
127
144
  *
@@ -144,6 +161,7 @@ function codexWorktreeGuardArgs(options) {
144
161
  [node_path_1.default.resolve(options.workspaceRoot)]: "deny",
145
162
  [node_path_1.default.resolve(options.worktreePath)]: "write",
146
163
  [node_path_1.default.resolve(options.repositoryPath)]: "write",
164
+ [codexGhConfigDir(options.workDir)]: "read",
147
165
  };
148
166
  for (const root of codexToolReadRoots()) {
149
167
  const resolved = node_path_1.default.resolve(root);
@@ -173,10 +191,15 @@ function codexWorktreeGuardArgs(options) {
173
191
  `default_permissions=${tomlString(CODEX_GUARD_PROFILE)}`,
174
192
  "-c",
175
193
  `permissions.${CODEX_GUARD_PROFILE}.filesystem=${tomlInlineTable(filesystem)}`,
176
- // Navarch coding tasks must be able to fetch dependencies and push their
177
- // branch. The profile still constrains filesystem access independently.
194
+ // A network-enabled permission profile still blocks every destination
195
+ // until it has at least one allow rule. Keep routine authenticated GitHub
196
+ // CLI and git traffic inside the sandbox so it does not become an
197
+ // Auto-review escalation, while leaving every non-GitHub destination
198
+ // blocked. The profile still constrains filesystem access independently.
178
199
  "-c",
179
200
  `permissions.${CODEX_GUARD_PROFILE}.network.enabled=true`,
201
+ "-c",
202
+ `permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable(CODEX_GITHUB_NETWORK_DOMAINS)}`,
180
203
  ];
181
204
  }
182
205
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
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",