@sagentlab/navarch-runtime 0.1.1 → 0.1.3
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 +123 -104
- package/dist/adapters/claude.cjs +13 -1
- package/dist/adapters/codex.cjs +73 -25
- package/dist/api.cjs +7 -12
- package/dist/claim-loop.cjs +7 -1
- package/dist/cli.cjs +38 -14
- package/dist/config.cjs +13 -4
- package/dist/exit-conditions.cjs +39 -21
- package/dist/git-worktree.cjs +145 -0
- package/dist/github-pr.cjs +87 -0
- package/dist/machine-store.cjs +3 -0
- package/dist/prompt.cjs +25 -1
- package/dist/sandbox.cjs +20 -6
- package/dist/session.cjs +181 -37
- package/dist/types.cjs +3 -10
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
|
|
|
7
7
|
machine.
|
|
8
8
|
|
|
9
9
|
The agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
|
|
10
|
-
selected
|
|
10
|
+
selected by the local machine via `--agent` or `NAVARCH_AGENT` (default
|
|
11
|
+
`claude-code`) — see
|
|
11
12
|
"Choosing an agent (Claude Code vs. Codex)" below.
|
|
12
13
|
|
|
13
14
|
See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
|
|
@@ -20,7 +21,7 @@ the API contract.
|
|
|
20
21
|
|
|
21
22
|
```sh
|
|
22
23
|
git clone <this repo> && cd sagentlab/runtime
|
|
23
|
-
./install.sh # checks node
|
|
24
|
+
./install.sh # checks node, npm install, npm run build
|
|
24
25
|
export NAVARCH_API_BASE=https://navarch.example.com
|
|
25
26
|
node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
|
|
26
27
|
node bin/navarch.cjs start
|
|
@@ -48,8 +49,8 @@ availability, and registration status without starting the daemon.
|
|
|
48
49
|
|
|
49
50
|
`connect` is the project-scoped sibling of `register` — "Connect an agent to
|
|
50
51
|
a project" (self-hosted-runner style, like a GitHub Actions self-hosted
|
|
51
|
-
runner token): a project **owner** mints a
|
|
52
|
-
|
|
52
|
+
runner token): a project **owner** mints a single-use token from the platform
|
|
53
|
+
UI (the project settings page's "Connect
|
|
53
54
|
an agent" panel, or the onboarding wizard's Agent step),
|
|
54
55
|
`POST /api/projects/:id/enrollment-tokens`, and pastes you the ready-to-run
|
|
55
56
|
command. Unlike `register`, no `NAVARCH_ENROLLMENT_SECRET` or
|
|
@@ -59,7 +60,7 @@ project only (it will never be dispatched work from any other project).
|
|
|
59
60
|
|
|
60
61
|
```sh
|
|
61
62
|
npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
|
|
62
|
-
--name my-agent-1 --api-base https://navarch.example.com
|
|
63
|
+
--name my-agent-1 --agent codex --api-base https://navarch.example.com
|
|
63
64
|
node bin/navarch.cjs start
|
|
64
65
|
```
|
|
65
66
|
|
|
@@ -72,11 +73,70 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
|
|
|
72
73
|
|
|
73
74
|
| Command | Purpose |
|
|
74
75
|
|---|---|
|
|
75
|
-
| `register --token <t> --name <n> [--
|
|
76
|
-
| `connect --token <t> --name <n> [--
|
|
77
|
-
| `start` | Runs the daemon
|
|
76
|
+
| `register --token <t> --name <n> [--agent claude-code\|codex] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
|
|
77
|
+
| `connect --token <t> --name <n> [--agent claude-code\|codex] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
|
|
78
|
+
| `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
|
|
78
79
|
| `doctor` | Prints resolved config + Docker/registration status; no side effects. |
|
|
79
80
|
|
|
81
|
+
## Active-task guidance
|
|
82
|
+
|
|
83
|
+
Guidance added to a task that is already running is delivered on that
|
|
84
|
+
task's next lease heartbeat (every
|
|
85
|
+
`NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS`, five minutes by default). The runtime
|
|
86
|
+
stops the current agent process and starts a **new agent turn** with the
|
|
87
|
+
original task context plus all guidance received so far. This is a turn
|
|
88
|
+
restart, not a new dispatch:
|
|
89
|
+
|
|
90
|
+
- The task keeps the same session and lease, and lease heartbeats continue.
|
|
91
|
+
- The replacement turn uses the same worktree (and the same sandbox container
|
|
92
|
+
in Docker mode), so committed and uncommitted changes from the interrupted
|
|
93
|
+
turn remain available. It should inspect those changes before continuing.
|
|
94
|
+
- The replacement turn is not a resumed Claude Code or Codex conversation.
|
|
95
|
+
The corrected prompt carries the prior task context and guidance instead.
|
|
96
|
+
- Transcript, token, and cost accounting is cumulative across turns: the
|
|
97
|
+
upload includes every turn under a separate label, and reported token and
|
|
98
|
+
cost totals include every turn. The lease is completed only after the
|
|
99
|
+
corrected turn finishes.
|
|
100
|
+
|
|
101
|
+
The daemon logs `restarting the agent turn in the same worktree` when it
|
|
102
|
+
delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
|
|
103
|
+
sooner, but keep it comfortably below the 15-minute lease TTL.
|
|
104
|
+
|
|
105
|
+
## Safely upgrading and restarting the daemon
|
|
106
|
+
|
|
107
|
+
The daemon does not currently drain sessions during shutdown: `SIGINT` or
|
|
108
|
+
`SIGTERM` stops new claims and heartbeats, then exits immediately. Stopping it
|
|
109
|
+
with an active session can interrupt the agent before it reports completion;
|
|
110
|
+
the control plane must then wait for the lease to expire and requeue the task.
|
|
111
|
+
Do not use an ordinary daemon restart as a way to deliver guidance.
|
|
112
|
+
|
|
113
|
+
Use this sequence for an upgrade:
|
|
114
|
+
|
|
115
|
+
1. Install or build the new runtime without stopping the existing process. For
|
|
116
|
+
a source checkout, update the checkout and run `npm ci && npm run build`
|
|
117
|
+
inside `runtime/`. For an npm deployment, select the exact version in the
|
|
118
|
+
service command, for example
|
|
119
|
+
`npx --yes @sagentlab/navarch-runtime@<version> start`.
|
|
120
|
+
2. In Navarch's **Fleet** view, wait until this machine shows `Sessions: 0/N`.
|
|
121
|
+
Check the daemon log once more for a newer `claimed task` message before
|
|
122
|
+
proceeding. If it claimed another task, let that session finish too.
|
|
123
|
+
3. Stop the old process through its service manager, or send `SIGTERM`/press
|
|
124
|
+
Ctrl-C. Do not use `SIGKILL` (`kill -9`). Ensure the old process has exited
|
|
125
|
+
before starting its replacement so two claim loops never run for one
|
|
126
|
+
machine identity.
|
|
127
|
+
4. Run `doctor` using the same service environment and
|
|
128
|
+
`NAVARCH_CONFIG_DIR`, then start the new version with that same environment.
|
|
129
|
+
Do **not** run `register` or `connect` again: the existing `machine.json`
|
|
130
|
+
contains the machine identity and token needed after the upgrade. Never
|
|
131
|
+
print, log, or copy the contents of `machine.json` or its token into upgrade
|
|
132
|
+
commands or diagnostics.
|
|
133
|
+
5. Confirm the startup log reports the expected machine, agent, and API base,
|
|
134
|
+
then verify that Fleet shows the machine online with a fresh heartbeat.
|
|
135
|
+
|
|
136
|
+
For a supervised service, make the stop timeout long enough for step 3 to
|
|
137
|
+
observe a normal exit, and keep the service's environment file/config directory
|
|
138
|
+
unchanged across the deployment.
|
|
139
|
+
|
|
80
140
|
## Configuration (`NAVARCH_*` env vars)
|
|
81
141
|
|
|
82
142
|
| Var | Default | Meaning |
|
|
@@ -87,27 +147,29 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
|
|
|
87
147
|
| `NAVARCH_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
|
|
88
148
|
| `NAVARCH_PROJECT_ID` | — | Alternative to `connect --project`. |
|
|
89
149
|
| `NAVARCH_CONFIG_DIR` | `~/.navarch` | Where `machine.json` lives. |
|
|
90
|
-
| `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` |
|
|
91
|
-
| `NAVARCH_MAX_SESSIONS` | `
|
|
92
|
-
| `NAVARCH_CAPABILITIES` | `docker-sandbox,shell` | Comma list reported at heartbeat/claim time. |
|
|
150
|
+
| `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
|
|
151
|
+
| `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
|
|
152
|
+
| `NAVARCH_CAPABILITIES` | `shell` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. |
|
|
93
153
|
| `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
|
|
94
154
|
| `NAVARCH_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
|
|
95
155
|
| `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
|
|
96
156
|
| `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
|
|
97
157
|
| `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
|
|
98
|
-
| `NAVARCH_SANDBOX_MODE` | `
|
|
158
|
+
| `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
|
|
99
159
|
| `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
|
|
100
|
-
| `NAVARCH_AGENT` | `claude-code` |
|
|
160
|
+
| `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code` or `codex`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
|
|
101
161
|
| `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
|
|
102
162
|
| `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
|
|
103
163
|
| `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
|
|
104
|
-
| `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--
|
|
164
|
+
| `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
|
|
105
165
|
| `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
|
|
106
166
|
|
|
107
167
|
## Choosing an agent (Claude Code vs. Codex)
|
|
108
168
|
|
|
109
|
-
Each machine
|
|
110
|
-
`
|
|
169
|
+
Each machine chooses its own agent CLI. Pass `--agent` while connecting to
|
|
170
|
+
persist the choice in local `machine.json`, override it for one daemon start
|
|
171
|
+
with `start --agent`, or set `NAVARCH_AGENT` in the machine's service
|
|
172
|
+
environment:
|
|
111
173
|
|
|
112
174
|
```sh
|
|
113
175
|
# Claude Code (default) — requires the `claude` CLI installed and
|
|
@@ -120,6 +182,9 @@ export NAVARCH_AGENT=claude-code
|
|
|
120
182
|
export NAVARCH_AGENT=codex
|
|
121
183
|
```
|
|
122
184
|
|
|
185
|
+
Priority is `start --agent` → `NAVARCH_AGENT` → the locally saved choice →
|
|
186
|
+
`claude-code`. The control plane does not choose the adapter.
|
|
187
|
+
|
|
123
188
|
Both adapters implement the same `AgentAdapter` interface
|
|
124
189
|
(`src/adapters/types.cts`) and run either directly on the host or via
|
|
125
190
|
`docker exec` in the session's sandbox container, exactly like the Claude
|
|
@@ -127,15 +192,20 @@ adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
|
|
|
127
192
|
`selectAdapter`) at the start of each session and passes `agent_type`
|
|
128
193
|
through to `complete()` unchanged by whatever happened during the run.
|
|
129
194
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
195
|
+
The control plane also resolves the project's model and the task's execution
|
|
196
|
+
profile on every claim. The runtime passes those values as per-session CLI
|
|
197
|
+
overrides (`codex exec --model ... -c model_reasoning_effort=...` or
|
|
198
|
+
`claude -p --model ... --effort ...`) and records the effective model, profile,
|
|
199
|
+
and effort on completion. Machine-wide extra arguments still configure other
|
|
200
|
+
CLI behavior; project/task policy wins for model and effort.
|
|
201
|
+
|
|
202
|
+
The Codex CLI invocation was verified against `codex-cli 0.144.1` on
|
|
203
|
+
2026-07-18. The runtime uses `codex exec "<prompt>" --json` and translates
|
|
204
|
+
the existing per-session MCP JSON into one-off `-c mcp_servers.*` overrides.
|
|
205
|
+
Machine and lease credentials are referenced through environment variables,
|
|
206
|
+
not placed in argv. The JSONL parser accepts the verified top-level
|
|
207
|
+
`item.completed` / `turn.completed` shape and retains the older `msg`
|
|
208
|
+
envelope as a compatibility fallback.
|
|
139
209
|
|
|
140
210
|
## Architecture
|
|
141
211
|
|
|
@@ -149,16 +219,19 @@ cli.cts
|
|
|
149
219
|
└─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
|
|
150
220
|
1. write prompt.md (prompt.cts renders the 4-layer context bundle)
|
|
151
221
|
2. api.issueSecrets() → held in memory only
|
|
152
|
-
3.
|
|
222
|
+
3. fetch the project's bare repository cache and create a
|
|
223
|
+
unique git worktree for this session; optionally mount it
|
|
224
|
+
into Docker when NAVARCH_SANDBOX_MODE=docker
|
|
153
225
|
4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
|
|
154
226
|
(adapters/types.cts) by NAVARCH_AGENT, then .run(...):
|
|
155
227
|
- claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
|
|
156
|
-
- codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json
|
|
228
|
+
- codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
|
|
157
229
|
heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
|
|
158
230
|
a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
|
|
159
231
|
5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
|
|
160
232
|
6. api.completeLease(), reporting agent_type: config.agentType
|
|
161
|
-
7. sandbox.wipe()
|
|
233
|
+
7. sandbox.wipe() when present; remove the session workspace
|
|
234
|
+
unconditionally (finally block)
|
|
162
235
|
```
|
|
163
236
|
|
|
164
237
|
`adapter.cts` (top-level) is now a backward-compat re-export of
|
|
@@ -186,43 +259,28 @@ package talks to `fetch` directly for control-plane traffic.
|
|
|
186
259
|
(GitHub PAT shapes, PEM private keys, generic `sk-...` tokens) as
|
|
187
260
|
defense-in-depth for values the registry didn't see directly.
|
|
188
261
|
|
|
189
|
-
##
|
|
262
|
+
## Control-plane contracts
|
|
190
263
|
|
|
191
|
-
`schema-design.md` §7
|
|
192
|
-
|
|
193
|
-
`POST /api/broker/issue` — those are implemented in `api.cts` exactly as
|
|
194
|
-
documented. Three routes WP-07 also needs are **not** enumerated there and
|
|
195
|
-
were inferred from the closest analogous shape (each is called out in
|
|
196
|
-
`types.cts` with an "ASSUMED" doc comment and isolated to one method in
|
|
197
|
-
`api.cts`):
|
|
264
|
+
`schema-design.md` §7 and the matching control-plane routes define every HTTP
|
|
265
|
+
contract used by `api.cts`, including:
|
|
198
266
|
|
|
199
|
-
1. **`POST /api/machines/register`** — machine
|
|
200
|
-
|
|
201
|
-
docs describe the *behavior* ("one command, prints machine token once")
|
|
202
|
-
but WP-01/WP-04/WP-05 own the actual admin-console/enrollment-token flow.
|
|
267
|
+
1. **`POST /api/machines/register`** — global machine registration via the
|
|
268
|
+
operator-configured enrollment secret.
|
|
203
269
|
2. **`POST /api/machines/:id/heartbeat`** — a machine-level heartbeat
|
|
204
270
|
distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
|
|
205
271
|
/`status` must update even when no task is claimed.
|
|
206
272
|
3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
|
|
207
|
-
Supabase Storage upload URL
|
|
208
|
-
storage, not in DB" note and WP-07's "transcript → Supabase Storage via a
|
|
209
|
-
control-plane upload URL" line.
|
|
210
|
-
|
|
211
|
-
Reconciling any of these against the real routes, once WP-01/WP-04/WP-05
|
|
212
|
-
land, means editing the corresponding method in `api.cts` — no other file
|
|
213
|
-
should need to change.
|
|
273
|
+
Supabase Storage upload URL for the session's redacted transcript.
|
|
214
274
|
|
|
215
|
-
`POST /api/machines/connect`
|
|
216
|
-
|
|
217
|
-
as `app/api/machines/connect/route.ts`, so the shapes in `types.cts`
|
|
218
|
-
(`ConnectMachineRequest`/`ConnectMachineResult`) are confirmed against the
|
|
219
|
-
real route, not inferred.
|
|
275
|
+
`POST /api/machines/connect` is the project-scoped alternative: it redeems a
|
|
276
|
+
single-use token minted by an owner through the onboarding or Fleet UI.
|
|
220
277
|
|
|
221
278
|
## What needs live verification
|
|
222
279
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
280
|
+
Most runtime behavior is covered offline. The Codex host adapter was also
|
|
281
|
+
probed against a real authenticated `codex-cli 0.144.1`; Docker and the full
|
|
282
|
+
production control-plane lifecycle still require live verification. Unit
|
|
283
|
+
tests cover:
|
|
226
284
|
|
|
227
285
|
- `api.cts` — request/response shapes, error mapping, auth header handling (`tests/api.test.cts`).
|
|
228
286
|
- `exit-conditions.cts` — every exit-condition → complete/fail mapping, priority order,
|
|
@@ -261,48 +319,19 @@ secrets absent from disk after exit"):
|
|
|
261
319
|
`costUsd` fall back to 0 (session.cts's existing default) — never a thrown
|
|
262
320
|
error — but cost/spend data silently goes missing until the shape is
|
|
263
321
|
reconciled here.
|
|
264
|
-
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
`--output-format json`, or no structured-output flag at all.
|
|
275
|
-
- That the event `msg.type` values used here (`agent_message`,
|
|
276
|
-
`token_count`, `task_complete`) exist, are spelled this way, and that
|
|
277
|
-
`token_count` events carry cumulative (not incremental/per-turn) totals —
|
|
278
|
-
`exit-conditions.cts#extractUsageFromCodexEvents` takes the *last* such
|
|
279
|
-
event's numbers, which double-counts or under-counts if that guess is
|
|
280
|
-
wrong.
|
|
281
|
-
- That `--mcp-config <path>` is accepted at all by `codex exec` — Codex CLI
|
|
282
|
-
documentation elsewhere describes MCP servers configured via
|
|
283
|
-
`~/.codex/config.toml`, not a per-invocation flag, so this may need to
|
|
284
|
-
become "write a config fragment into the sandbox first" instead.
|
|
285
|
-
- Whether a real `codex exec` run needs an explicit non-interactive/
|
|
286
|
-
approval-bypass flag (e.g. something like `--full-auto` or
|
|
287
|
-
`--dangerously-bypass-approvals-and-sandbox` per published Codex CLI
|
|
288
|
-
docs) to avoid blocking on an approval prompt inside the already-isolated
|
|
289
|
-
Docker sandbox — deliberately **not** hardcoded, left to
|
|
290
|
-
`NAVARCH_CODEX_EXTRA_ARGS` until confirmed, since guessing the wrong
|
|
291
|
-
flag here could silently disable sandboxing rather than just fail loudly.
|
|
292
|
-
- Whether the Codex CLI even authenticates/runs non-interactively the same
|
|
293
|
-
way `claude` does (API key vs. ChatGPT-account OAuth device flow) — this
|
|
294
|
-
adapter assumes "already authenticated on the machine" exactly like the
|
|
295
|
-
Claude Code prerequisite, but cannot confirm that story is equivalent.
|
|
296
|
-
|
|
297
|
-
If any of this is wrong, `parseCodexJsonEvents` returns an empty array and
|
|
298
|
-
the adapter's `attachUsage()` leaves `tokensIn`/`tokensOut`/`costUsd`/
|
|
299
|
-
`reportText` unset — `mapExitCondition` then falls back to raw stdout for
|
|
300
|
-
the report — same never-throw degradation as the Claude path, just with
|
|
301
|
-
more to reconcile once a real binary exists.
|
|
322
|
+
- A full Codex task that initializes the production MCP server, edits a
|
|
323
|
+
worktree, pushes a branch, opens a PR, and completes its lease. The CLI
|
|
324
|
+
flags, per-run MCP override keys, stdin behavior, and JSONL event/usage
|
|
325
|
+
shape are now verified locally; the next dogfood run covers their
|
|
326
|
+
production composition.
|
|
327
|
+
- Whether Docker-mode Codex should opt into
|
|
328
|
+
`--dangerously-bypass-approvals-and-sandbox`. It is intentionally not a
|
|
329
|
+
default: host mode is not an external sandbox, and silently disabling
|
|
330
|
+
Codex's protections there would be unsafe. Operators can still add the
|
|
331
|
+
flag explicitly through `NAVARCH_CODEX_EXTRA_ARGS` on an isolated machine.
|
|
302
332
|
- The full Docker sandbox lifecycle against a real Docker daemon (rootless
|
|
303
333
|
behavior, tmpfs env injection, git clone with a real PAT, `docker exec`
|
|
304
334
|
timeout/kill semantics under `AbortSignal`).
|
|
305
|
-
- The three assumed endpoints above, once they exist.
|
|
306
335
|
- Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
|
|
307
336
|
already-reassigned lease) — `session.cts` aborts the running adapter
|
|
308
337
|
process and still attempts a best-effort `complete()` call, which the
|
|
@@ -340,13 +369,3 @@ not match, making it a clean, zero-touch way to keep this package fully
|
|
|
340
369
|
isolated. Vite/Vitest's default esbuild transform filter also excludes
|
|
341
370
|
`.cts` by default — `vitest.config.cts` overrides it (`esbuild.include`) so
|
|
342
371
|
tests are actually type-stripped and run.
|
|
343
|
-
|
|
344
|
-
## Discovered scope (flagging, not editing the plan doc)
|
|
345
|
-
|
|
346
|
-
- Machine registration, machine-level heartbeat, and the transcript
|
|
347
|
-
upload-URL endpoint are not in `schema-design.md` §7's API surface list —
|
|
348
|
-
see "Assumptions to confirm" above. Recommend WP-01/WP-04/WP-05 add these
|
|
349
|
-
three routes explicitly to the schema doc once designed.
|
|
350
|
-
- No admin-console UI exists yet to actually mint an `enrollment_token` for
|
|
351
|
-
`register` to redeem — that's WP-08 (Fleet view) / WP-01 (admin console)
|
|
352
|
-
territory; this package assumes it will exist.
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -37,7 +37,19 @@ async function runClaudeCodeAdapter(options) {
|
|
|
37
37
|
args.push("--output-format", "json");
|
|
38
38
|
}
|
|
39
39
|
args.push(...options.extraArgs);
|
|
40
|
-
|
|
40
|
+
// Project/task policy is appended after machine-wide extra arguments so a
|
|
41
|
+
// dispatched session consistently uses the settings recorded by Navarch.
|
|
42
|
+
if (options.model)
|
|
43
|
+
args.push("--model", options.model);
|
|
44
|
+
if (options.reasoningEffort)
|
|
45
|
+
args.push("--effort", options.reasoningEffort);
|
|
46
|
+
const runOptions = options.reasoningEffort
|
|
47
|
+
? {
|
|
48
|
+
...options,
|
|
49
|
+
env: { ...options.env, CLAUDE_CODE_EFFORT_LEVEL: options.reasoningEffort },
|
|
50
|
+
}
|
|
51
|
+
: options;
|
|
52
|
+
const raw = runOptions.dockerExec ? await runViaDocker(runOptions, args) : await runOnHost(runOptions, args);
|
|
41
53
|
return attachUsage(raw);
|
|
42
54
|
}
|
|
43
55
|
/** Parses stdout for `claude -p --output-format json` usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.codexAdapter = void 0;
|
|
4
4
|
exports.runCodexAdapter = runCodexAdapter;
|
|
5
5
|
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
6
7
|
const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
7
8
|
/**
|
|
8
9
|
* Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
|
|
@@ -13,31 +14,21 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
|
13
14
|
* timeout/AbortSignal handling, same "attach best-effort usage onto the raw
|
|
14
15
|
* result" shape — only the CLI invocation and output parsing differ.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
* AGAINST A REAL INSTALL. There is no `codex` binary available in this
|
|
18
|
-
* offline build environment, so (mirroring how claude.cts documents its own
|
|
19
|
-
* `claude -p --output-format json` assumption) this adapter is a best-effort
|
|
20
|
-
* implementation against the Codex CLI's publicly documented shape, not a
|
|
21
|
-
* verified one:
|
|
17
|
+
* Verified against codex-cli 0.144.1 on 2026-07-18:
|
|
22
18
|
*
|
|
23
|
-
* codex exec "<prompt>" --json [
|
|
19
|
+
* codex exec "<prompt>" --json [-c mcp_servers.<name>.<key>=<value>] [...extraArgs]
|
|
24
20
|
*
|
|
25
21
|
* - `exec <prompt>` — ASSUMED to be Codex CLI's non-interactive/headless
|
|
26
22
|
* subcommand (the `codex exec` "automation mode" analog of `claude -p`):
|
|
27
23
|
* runs the prompt to completion without the interactive TUI and exits,
|
|
28
24
|
* printing its result to stdout.
|
|
29
|
-
* - `--json`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* is no confirmed Codex CLI flag of this name. Codex CLI is documented
|
|
37
|
-
* elsewhere to configure MCP servers via a `~/.codex/config.toml`
|
|
38
|
-
* `mcp_servers` table rather than a per-invocation flag, so this flag may
|
|
39
|
-
* need to become "write a config.toml fragment into the sandbox before
|
|
40
|
-
* exec" instead once a real binary is available to test against.
|
|
25
|
+
* - `--json` emits top-level `thread.started`, `turn.started`,
|
|
26
|
+
* `item.completed`, and `turn.completed` JSONL events. See
|
|
27
|
+
* exit-conditions.cts for the tolerant parser.
|
|
28
|
+
* - Codex has no `--mcp-config` flag. The runtime's existing Claude-shaped
|
|
29
|
+
* per-session JSON is translated into one-off `-c mcp_servers.*` overrides.
|
|
30
|
+
* Authentication and custom-header values are passed through environment
|
|
31
|
+
* variables so machine/lease credentials never appear in argv.
|
|
41
32
|
* - Sandboxing/approvals: a real `codex exec` may prompt for
|
|
42
33
|
* approval/sandbox-escalation on some actions by default; because this
|
|
43
34
|
* runtime already isolates the session in its own Docker container (or,
|
|
@@ -55,17 +46,65 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
|
55
46
|
*/
|
|
56
47
|
async function runCodexAdapter(options) {
|
|
57
48
|
const args = ["exec", options.prompt];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
args.push(
|
|
61
|
-
}
|
|
49
|
+
const env = { ...options.env };
|
|
50
|
+
if (options.mcpConfigPath)
|
|
51
|
+
args.push(...(await codexMcpArgs(options.mcpConfigPath, env)));
|
|
62
52
|
if (!options.extraArgs.includes("--json")) {
|
|
63
53
|
args.push("--json");
|
|
64
54
|
}
|
|
65
55
|
args.push(...options.extraArgs);
|
|
66
|
-
|
|
56
|
+
if (options.model)
|
|
57
|
+
args.push("--model", options.model);
|
|
58
|
+
if (options.reasoningEffort) {
|
|
59
|
+
args.push("-c", `model_reasoning_effort=${tomlString(options.reasoningEffort)}`);
|
|
60
|
+
}
|
|
61
|
+
const runOptions = { ...options, env };
|
|
62
|
+
const raw = runOptions.dockerExec
|
|
63
|
+
? await runViaDocker(runOptions, args)
|
|
64
|
+
: await runOnHost(runOptions, args);
|
|
67
65
|
return attachUsage(raw);
|
|
68
66
|
}
|
|
67
|
+
/** Convert Claude's per-session MCP JSON into Codex one-off TOML overrides. */
|
|
68
|
+
async function codexMcpArgs(path, env) {
|
|
69
|
+
const parsed = JSON.parse(await node_fs_1.promises.readFile(path, "utf8"));
|
|
70
|
+
const args = [];
|
|
71
|
+
for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
|
|
72
|
+
if (!server.url)
|
|
73
|
+
continue;
|
|
74
|
+
const key = tomlKey(name);
|
|
75
|
+
args.push("-c", `mcp_servers.${key}.url=${tomlString(server.url)}`);
|
|
76
|
+
args.push("-c", `mcp_servers.${key}.required=true`);
|
|
77
|
+
const envHeaders = {};
|
|
78
|
+
let headerIndex = 0;
|
|
79
|
+
for (const [header, value] of Object.entries(server.headers ?? {})) {
|
|
80
|
+
const envName = `NAVARCH_CODEX_MCP_${safeEnvSegment(name)}_${headerIndex++}`;
|
|
81
|
+
if (header.toLowerCase() === "authorization" && value.startsWith("Bearer ")) {
|
|
82
|
+
env[envName] = value.slice("Bearer ".length);
|
|
83
|
+
args.push("-c", `mcp_servers.${key}.bearer_token_env_var=${tomlString(envName)}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
env[envName] = value;
|
|
87
|
+
envHeaders[header] = envName;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(envHeaders).length > 0) {
|
|
91
|
+
const table = Object.entries(envHeaders)
|
|
92
|
+
.map(([header, envName]) => `${tomlString(header)}=${tomlString(envName)}`)
|
|
93
|
+
.join(",");
|
|
94
|
+
args.push("-c", `mcp_servers.${key}.env_http_headers={${table}}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return args;
|
|
98
|
+
}
|
|
99
|
+
function tomlString(value) {
|
|
100
|
+
return JSON.stringify(value);
|
|
101
|
+
}
|
|
102
|
+
function tomlKey(value) {
|
|
103
|
+
return /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value);
|
|
104
|
+
}
|
|
105
|
+
function safeEnvSegment(value) {
|
|
106
|
+
return value.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
107
|
+
}
|
|
69
108
|
/** Parses stdout for `codex exec --json` usage/final-message events and folds them onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd/reportText unset when nothing parses — see exit-conditions.cts#parseCodexJsonEvents). */
|
|
70
109
|
function attachUsage(result) {
|
|
71
110
|
const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
|
|
@@ -91,6 +130,10 @@ async function runOnHost(options, args) {
|
|
|
91
130
|
cwd: options.cwd,
|
|
92
131
|
env: { ...process.env, ...options.env },
|
|
93
132
|
});
|
|
133
|
+
// `codex exec` appends piped stdin to the prompt. The spawned process gets
|
|
134
|
+
// a pipe by default, so close it immediately or it can wait forever for
|
|
135
|
+
// input even though the full prompt was supplied as an argument.
|
|
136
|
+
child.stdin?.end();
|
|
94
137
|
const timer = setTimeout(() => {
|
|
95
138
|
timedOut = true;
|
|
96
139
|
child.kill("SIGKILL");
|
|
@@ -133,8 +176,13 @@ async function runViaDocker(options, args) {
|
|
|
133
176
|
};
|
|
134
177
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
135
178
|
try {
|
|
136
|
-
|
|
179
|
+
// Forward values through the docker CLI process environment and put only
|
|
180
|
+
// variable names in argv. This keeps generated MCP bearer/header values
|
|
181
|
+
// out of `ps` while making the host and Docker paths equivalent.
|
|
182
|
+
const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
|
|
183
|
+
const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], {
|
|
137
184
|
timeoutMs: options.timeoutMs,
|
|
185
|
+
env: { ...process.env, ...options.env },
|
|
138
186
|
});
|
|
139
187
|
return {
|
|
140
188
|
exitCode: result.code,
|
package/dist/api.cjs
CHANGED
|
@@ -15,13 +15,8 @@ exports.NavarchApiError = NavarchApiError;
|
|
|
15
15
|
/**
|
|
16
16
|
* Typed client for the Navarch control-plane API surface WP-07 depends on:
|
|
17
17
|
* dispatch/claim, per-lease heartbeat, complete, and broker/issue
|
|
18
|
-
* (schema-design.md §7,
|
|
19
|
-
*
|
|
20
|
-
* this package assumes (see types.cts doc comments — flagged "ASSUMED").
|
|
21
|
-
*
|
|
22
|
-
* Every request path lives in exactly one method here so wiring up the real
|
|
23
|
-
* deployment, once WP-01/WP-04/WP-05 land, is a base URL + token (and at most
|
|
24
|
-
* a one-line path fix for the assumed routes) — not a rewrite.
|
|
18
|
+
* (schema-design.md §7), plus machine registration, machine heartbeat, and
|
|
19
|
+
* transcript upload. Every request path lives in exactly one method here.
|
|
25
20
|
*/
|
|
26
21
|
class NavarchApiClient {
|
|
27
22
|
baseUrl;
|
|
@@ -65,7 +60,7 @@ class NavarchApiClient {
|
|
|
65
60
|
return null;
|
|
66
61
|
return parsed;
|
|
67
62
|
}
|
|
68
|
-
/**
|
|
63
|
+
/** Global machine registration using the operator-configured enrollment secret. */
|
|
69
64
|
async registerMachine(req) {
|
|
70
65
|
const result = await this.request("POST", "/api/machines/register", req, { auth: false });
|
|
71
66
|
if (!result)
|
|
@@ -87,7 +82,7 @@ class NavarchApiClient {
|
|
|
87
82
|
throw new Error("connectMachine: empty response from control plane.");
|
|
88
83
|
return result;
|
|
89
84
|
}
|
|
90
|
-
/**
|
|
85
|
+
/** Machine-level capacity heartbeat, independent of lease heartbeats. */
|
|
91
86
|
async machineHeartbeat(machineId, req) {
|
|
92
87
|
const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/heartbeat`, req);
|
|
93
88
|
if (!result)
|
|
@@ -102,8 +97,8 @@ class NavarchApiClient {
|
|
|
102
97
|
return result;
|
|
103
98
|
}
|
|
104
99
|
/** schema-design.md §7 — `POST /api/dispatch/:leaseId/heartbeat`. */
|
|
105
|
-
async heartbeatLease(leaseId) {
|
|
106
|
-
const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/heartbeat`,
|
|
100
|
+
async heartbeatLease(leaseId, req = {}) {
|
|
101
|
+
const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/heartbeat`, req);
|
|
107
102
|
if (!result)
|
|
108
103
|
throw new Error("heartbeatLease: empty response from control plane.");
|
|
109
104
|
return result;
|
|
@@ -121,7 +116,7 @@ class NavarchApiClient {
|
|
|
121
116
|
throw new Error("issueSecrets: empty response from control plane.");
|
|
122
117
|
return result;
|
|
123
118
|
}
|
|
124
|
-
/**
|
|
119
|
+
/** Create a signed upload target for this lease's redacted transcript. */
|
|
125
120
|
async getTranscriptUploadUrl(leaseId) {
|
|
126
121
|
const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/transcript-upload-url`, {});
|
|
127
122
|
if (!result)
|
package/dist/claim-loop.cjs
CHANGED
|
@@ -19,6 +19,7 @@ class ClaimLoop {
|
|
|
19
19
|
runSession;
|
|
20
20
|
timer = null;
|
|
21
21
|
stopped = false;
|
|
22
|
+
claimInFlight = false;
|
|
22
23
|
constructor(api, config, capacity, runSession) {
|
|
23
24
|
this.api = api;
|
|
24
25
|
this.config = config;
|
|
@@ -37,8 +38,9 @@ class ClaimLoop {
|
|
|
37
38
|
this.timer = null;
|
|
38
39
|
}
|
|
39
40
|
async tick() {
|
|
40
|
-
if (this.stopped || !this.capacity.hasCapacity())
|
|
41
|
+
if (this.stopped || this.claimInFlight || !this.capacity.hasCapacity())
|
|
41
42
|
return;
|
|
43
|
+
this.claimInFlight = true;
|
|
42
44
|
try {
|
|
43
45
|
// Pre-allocate the session id BEFORE claiming (author-exclusion timing):
|
|
44
46
|
// the dispatcher records it on the lease and excludes it from review
|
|
@@ -47,6 +49,7 @@ class ClaimLoop {
|
|
|
47
49
|
const claimed = await this.api.claim({
|
|
48
50
|
available_capacity: this.capacity.available(),
|
|
49
51
|
capabilities: this.config.capabilities,
|
|
52
|
+
agent_type: this.config.agentType,
|
|
50
53
|
session_id: sessionId,
|
|
51
54
|
});
|
|
52
55
|
if (!claimed)
|
|
@@ -70,6 +73,9 @@ class ClaimLoop {
|
|
|
70
73
|
log.warn(`claim failed: ${String(err)}`);
|
|
71
74
|
}
|
|
72
75
|
}
|
|
76
|
+
finally {
|
|
77
|
+
this.claimInFlight = false;
|
|
78
|
+
}
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
exports.ClaimLoop = ClaimLoop;
|