@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 +35 -12
- package/dist/api.cjs +48 -21
- package/dist/config.cjs +16 -1
- package/dist/exit-conditions.cjs +1 -1
- package/dist/git-worktree.cjs +22 -0
- package/dist/github-pr.cjs +50 -30
- package/dist/heartbeat-loop.cjs +44 -16
- package/dist/session.cjs +166 -31
- package/dist/upload.cjs +18 -1
- package/dist/usage-limits.cjs +400 -0
- package/dist/worktree-guard.cjs +25 -2
- package/package.json +1 -1
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
|
|
10
|
-
Gemini CLI**
|
|
11
|
-
|
|
12
|
-
|
|
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` | `
|
|
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
|
|
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
|
|
318
|
-
|
|
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
|
-
#
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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",
|
|
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
|
}
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -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
|
package/dist/github-pr.cjs
CHANGED
|
@@ -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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/heartbeat-loop.cjs
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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(
|
|
273
|
+
await sandbox.injectEnv(sessionEnv);
|
|
267
274
|
}
|
|
268
|
-
// The control plane
|
|
269
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
424
|
+
const rejection = completionRejection(err);
|
|
398
425
|
if (!rejection)
|
|
399
426
|
throw err;
|
|
400
|
-
const redactedRejection = (0, redact_cjs_1.redactText)(rejection, knownSecrets);
|
|
401
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -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
|
-
//
|
|
177
|
-
//
|
|
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