@sagentlab/navarch-runtime 0.1.0
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 +351 -0
- package/bin/navarch.cjs +12 -0
- package/dist/adapter.cjs +22 -0
- package/dist/adapters/claude.cjs +135 -0
- package/dist/adapters/codex.cjs +167 -0
- package/dist/adapters/index.cjs +32 -0
- package/dist/adapters/types.cjs +2 -0
- package/dist/api.cjs +140 -0
- package/dist/capacity.cjs +41 -0
- package/dist/claim-loop.cjs +64 -0
- package/dist/cli.cjs +203 -0
- package/dist/config.cjs +55 -0
- package/dist/exit-conditions.cjs +191 -0
- package/dist/heartbeat-loop.cjs +47 -0
- package/dist/logger.cjs +20 -0
- package/dist/machine-store.cjs +58 -0
- package/dist/mcp-config.cjs +41 -0
- package/dist/prompt.cjs +33 -0
- package/dist/redact.cjs +61 -0
- package/dist/sandbox.cjs +166 -0
- package/dist/session.cjs +196 -0
- package/dist/types.cjs +19 -0
- package/dist/upload.cjs +19 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# navarch-runtime
|
|
2
|
+
|
|
3
|
+
The machine-side half of Navarch (WP-07): registers this machine with the
|
|
4
|
+
control plane, then loops claim → sandbox → agent CLI → report until you stop
|
|
5
|
+
it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
|
|
6
|
+
— this directory is a self-contained package you can `npx` on any fresh
|
|
7
|
+
machine.
|
|
8
|
+
|
|
9
|
+
The agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
|
|
10
|
+
selected per machine via `FLOTILLA_AGENT` (default `claude-code`) — see
|
|
11
|
+
"Choosing an agent (Claude Code vs. Codex)" below.
|
|
12
|
+
|
|
13
|
+
See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
|
|
14
|
+
§3.8/§3.9/§3.11 and [`docs/flotilla/implementation-plan.md`](../docs/flotilla/implementation-plan.md)
|
|
15
|
+
WP-07 for the design this implements, and
|
|
16
|
+
[`docs/flotilla/schema-design.md`](../docs/flotilla/schema-design.md) §7 for
|
|
17
|
+
the API contract.
|
|
18
|
+
|
|
19
|
+
## Quick start on a fresh machine
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
git clone <this repo> && cd sagentlab/runtime
|
|
23
|
+
./install.sh # checks node/docker, npm install, npm run build
|
|
24
|
+
export FLOTILLA_API_BASE=https://flotilla.example.com
|
|
25
|
+
node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
|
|
26
|
+
node bin/navarch.cjs start
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or, once dependencies are installed:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm run build
|
|
33
|
+
FLOTILLA_API_BASE=https://flotilla.example.com npm run register -- --token <enrollment-token> --name my-machine-1
|
|
34
|
+
npm start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`register` is the one command that prints the machine's auth token — exactly
|
|
38
|
+
once, to stdout. It is stored at `$FLOTILLA_CONFIG_DIR/machine.json` (default
|
|
39
|
+
`~/.flotilla/machine.json`, mode `0600`) and never echoed again. For
|
|
40
|
+
twelve-factor deployments (systemd `EnvironmentFile`, container secrets),
|
|
41
|
+
skip `register` and set `FLOTILLA_MACHINE_TOKEN` + `FLOTILLA_MACHINE_ID`
|
|
42
|
+
directly.
|
|
43
|
+
|
|
44
|
+
Run `node bin/navarch.cjs doctor` any time to print resolved config, Docker
|
|
45
|
+
availability, and registration status without starting the daemon.
|
|
46
|
+
|
|
47
|
+
### Connecting an agent to one project
|
|
48
|
+
|
|
49
|
+
`connect` is the project-scoped sibling of `register` — "Connect an agent to
|
|
50
|
+
a project" (self-hosted-runner style, like a GitHub Actions self-hosted
|
|
51
|
+
runner token): a project **owner** mints a short-lived (60 minute),
|
|
52
|
+
single-use token from the platform UI (the project settings page's "Connect
|
|
53
|
+
an agent" panel, or the onboarding wizard's Agent step),
|
|
54
|
+
`POST /api/projects/:id/enrollment-tokens`, and pastes you the ready-to-run
|
|
55
|
+
command. Unlike `register`, no `FLOTILLA_ENROLLMENT_SECRET` or
|
|
56
|
+
`--owner-zone` is needed — the token is already scoped to exactly one
|
|
57
|
+
project, and the resulting machine's `project_bindings` is set to that
|
|
58
|
+
project only (it will never be dispatched work from any other project).
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
|
|
62
|
+
--name my-agent-1 --api-base https://flotilla.example.com
|
|
63
|
+
node bin/navarch.cjs start
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(`@sagentlab/navarch-runtime` is not yet published to npm — see "What needs
|
|
67
|
+
live verification" below for the from-source equivalent, i.e. the
|
|
68
|
+
`git clone` + `./install.sh` + `node bin/navarch.cjs connect ...` flow
|
|
69
|
+
above.)
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
| Command | Purpose |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `register --token <t> --name <n> [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]` | Registers this machine (global enrollment secret), prints the token once. |
|
|
76
|
+
| `connect --token <t> --name <n> [--project <id>] [--capabilities a,b] [--max-sessions N] [--api-base url]` | Connects this machine to exactly one project (project-scoped enrollment token), prints the token once. |
|
|
77
|
+
| `start` | Runs the daemon: machine heartbeat loop + claim loop, `FLOTILLA_MAX_SESSIONS` concurrent task sessions. |
|
|
78
|
+
| `doctor` | Prints resolved config + Docker/registration status; no side effects. |
|
|
79
|
+
|
|
80
|
+
## Configuration (`FLOTILLA_*` env vars)
|
|
81
|
+
|
|
82
|
+
| Var | Default | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `FLOTILLA_API_BASE` | `http://localhost:3000` | Control-plane base URL. |
|
|
85
|
+
| `FLOTILLA_MACHINE_TOKEN` / `FLOTILLA_MACHINE_ID` | — | Skip `register`/the config file; twelve-factor auth. |
|
|
86
|
+
| `FLOTILLA_MACHINE_NAME` | — | Used by `register`. |
|
|
87
|
+
| `FLOTILLA_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
|
|
88
|
+
| `FLOTILLA_PROJECT_ID` | — | Alternative to `connect --project`. |
|
|
89
|
+
| `FLOTILLA_CONFIG_DIR` | `~/.flotilla` | Where `machine.json` lives. |
|
|
90
|
+
| `FLOTILLA_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Per-session working directories (host side; mounted into each container). |
|
|
91
|
+
| `FLOTILLA_MAX_SESSIONS` | `3` | Capacity cap — see `src/capacity.cts`. |
|
|
92
|
+
| `FLOTILLA_CAPABILITIES` | `docker-sandbox,shell` | Comma list reported at heartbeat/claim time. |
|
|
93
|
+
| `FLOTILLA_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
|
|
94
|
+
| `FLOTILLA_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
|
|
95
|
+
| `FLOTILLA_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
|
|
96
|
+
| `FLOTILLA_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
|
|
97
|
+
| `FLOTILLA_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
|
|
98
|
+
| `FLOTILLA_SANDBOX_MODE` | `docker` | `docker` or `host` (host mode skips the container — dev/debug only, no isolation). |
|
|
99
|
+
| `FLOTILLA_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
|
|
100
|
+
| `FLOTILLA_AGENT` | `claude-code` | Which agent CLI runs sessions: `claude-code` or `codex`. Any other value falls back to `claude-code`. Recorded as `agent_type` on the `complete()` call so the sessions row shows which one ran. |
|
|
101
|
+
| `FLOTILLA_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
|
|
102
|
+
| `FLOTILLA_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
|
|
103
|
+
| `FLOTILLA_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
|
|
104
|
+
| `FLOTILLA_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config`/`--json` (Codex). |
|
|
105
|
+
| `FLOTILLA_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
|
|
106
|
+
|
|
107
|
+
## Choosing an agent (Claude Code vs. Codex)
|
|
108
|
+
|
|
109
|
+
Each machine runs sessions with exactly one agent CLI, set once via
|
|
110
|
+
`FLOTILLA_AGENT`:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
# Claude Code (default) — requires the `claude` CLI installed and
|
|
114
|
+
# authenticated on this machine (or FLOTILLA_CLAUDE_BIN pointing at it).
|
|
115
|
+
export FLOTILLA_AGENT=claude-code
|
|
116
|
+
|
|
117
|
+
# OpenAI Codex — requires the `codex` CLI installed and authenticated on
|
|
118
|
+
# this machine (or FLOTILLA_CODEX_BIN pointing at it), analogous to the
|
|
119
|
+
# Claude Code prerequisite above.
|
|
120
|
+
export FLOTILLA_AGENT=codex
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Both adapters implement the same `AgentAdapter` interface
|
|
124
|
+
(`src/adapters/types.cts`) and run either directly on the host or via
|
|
125
|
+
`docker exec` in the session's sandbox container, exactly like the Claude
|
|
126
|
+
adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
|
|
127
|
+
`selectAdapter`) at the start of each session and passes `agent_type`
|
|
128
|
+
through to `complete()` unchanged by whatever happened during the run.
|
|
129
|
+
|
|
130
|
+
**The Codex CLI invocation (`codex exec "<prompt>" --json [--mcp-config
|
|
131
|
+
<path>]`) is unverified** — there is no `codex` binary available to test
|
|
132
|
+
against in this offline build environment, so every flag and the JSONL
|
|
133
|
+
output shape it's assumed to produce are inferred by analogy with the Claude
|
|
134
|
+
Code adapter's own documented `--output-format json` assumption. See
|
|
135
|
+
`src/adapters/codex.cts`'s module doc comment and
|
|
136
|
+
`src/exit-conditions.cts`'s `parseCodexJsonEvents` doc comment for the full
|
|
137
|
+
list of assumptions to confirm once a real `codex` binary is available, and
|
|
138
|
+
"What needs live verification" below.
|
|
139
|
+
|
|
140
|
+
## Architecture
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
cli.cts
|
|
144
|
+
├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
|
|
145
|
+
├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
|
|
146
|
+
└─ start
|
|
147
|
+
├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every FLOTILLA_HEARTBEAT_INTERVAL_MS]
|
|
148
|
+
└─ ClaimLoop (claim-loop.cts) → api.claim() [every FLOTILLA_POLL_INTERVAL_MS, gated by CapacityTracker]
|
|
149
|
+
└─ runSession (session.cts), one per claimed lease, run concurrently up to FLOTILLA_MAX_SESSIONS:
|
|
150
|
+
1. write prompt.md (prompt.cts renders the 4-layer context bundle)
|
|
151
|
+
2. api.issueSecrets() → held in memory only
|
|
152
|
+
3. DockerSandbox.create/injectEnv/cloneRepo (sandbox.cts)
|
|
153
|
+
4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
|
|
154
|
+
(adapters/types.cts) by FLOTILLA_AGENT, then .run(...):
|
|
155
|
+
- claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
|
|
156
|
+
- codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json --mcp-config <path>` (ASSUMED)
|
|
157
|
+
heartbeating the lease every FLOTILLA_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
|
|
158
|
+
a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
|
|
159
|
+
5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
|
|
160
|
+
6. api.completeLease(), reporting agent_type: config.agentType
|
|
161
|
+
7. sandbox.wipe() unconditionally (finally block)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`adapter.cts` (top-level) is now a backward-compat re-export of
|
|
165
|
+
`adapters/claude.cts` — new code should import `adapters/index.cts` (for
|
|
166
|
+
`selectAdapter`) or `adapters/claude.cts` / `adapters/codex.cts` directly.
|
|
167
|
+
|
|
168
|
+
`api.cts` is the single choke point for every HTTP call; nothing else in the
|
|
169
|
+
package talks to `fetch` directly for control-plane traffic.
|
|
170
|
+
|
|
171
|
+
## Secret handling
|
|
172
|
+
|
|
173
|
+
- Task secrets come from `POST /api/broker/issue` **once**, at session start,
|
|
174
|
+
and are held only in memory (`session.cts`) — never written to the host
|
|
175
|
+
filesystem.
|
|
176
|
+
- Inside the sandbox, `DockerSandbox.injectEnv()` writes `KEY=value` lines to
|
|
177
|
+
a **tmpfs-backed** file (`/tmp/session.env`) over `docker exec -i` **stdin**
|
|
178
|
+
— never a `docker run -e` flag (visible via `docker inspect`) and never a
|
|
179
|
+
host-side file. The container's `/tmp` and `/run` are tmpfs, so nothing
|
|
180
|
+
persists past `wipe()`.
|
|
181
|
+
- `cloneRepo()` wires a git credential helper that echoes `$GITHUB_TOKEN` —
|
|
182
|
+
the literal token value never appears as a literal string in any argv the
|
|
183
|
+
host's `ps` can see; only the variable *reference* does.
|
|
184
|
+
- Before upload, `redact.cts` scrubs the transcript against every value the
|
|
185
|
+
session's `SecretRegistry` actually saw, plus pattern-based fallbacks
|
|
186
|
+
(GitHub PAT shapes, PEM private keys, generic `sk-...` tokens) as
|
|
187
|
+
defense-in-depth for values the registry didn't see directly.
|
|
188
|
+
|
|
189
|
+
## Assumptions to confirm (control-plane contracts not yet pinned)
|
|
190
|
+
|
|
191
|
+
`schema-design.md` §7 enumerates `POST /api/dispatch/claim`, `POST
|
|
192
|
+
/api/dispatch/:leaseId/heartbeat`, `POST /api/dispatch/:leaseId/complete`, and
|
|
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`):
|
|
198
|
+
|
|
199
|
+
1. **`POST /api/machines/register`** — machine self-registration via a
|
|
200
|
+
short-lived enrollment token (issued out-of-band by an owner/admin). The
|
|
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.
|
|
203
|
+
2. **`POST /api/machines/:id/heartbeat`** — a machine-level heartbeat
|
|
204
|
+
distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
|
|
205
|
+
/`status` must update even when no task is claimed.
|
|
206
|
+
3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
|
|
207
|
+
Supabase Storage upload URL, per `sessions.transcript_url`'s "object
|
|
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.
|
|
214
|
+
|
|
215
|
+
`POST /api/machines/connect` (`api.cts#connectMachine`, the `connect`
|
|
216
|
+
command) is **not** in this ASSUMED list — it was built in the same change
|
|
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.
|
|
220
|
+
|
|
221
|
+
## What needs live verification
|
|
222
|
+
|
|
223
|
+
This was built and tested offline (no Docker daemon, no `claude` or `codex`
|
|
224
|
+
binary, no live control-plane API in this environment). Unit tests cover
|
|
225
|
+
everything that can be verified without those:
|
|
226
|
+
|
|
227
|
+
- `api.cts` — request/response shapes, error mapping, auth header handling (`tests/api.test.cts`).
|
|
228
|
+
- `exit-conditions.cts` — every exit-condition → complete/fail mapping, priority order,
|
|
229
|
+
plus Claude's and Codex's usage/report parsing (`tests/exit-conditions.test.cts`).
|
|
230
|
+
- `redact.cts` — exact-value and pattern-based redaction (`tests/redact.test.cts`).
|
|
231
|
+
- `capacity.cts` — capacity math and acquire/release bookkeeping (`tests/capacity.test.cts`).
|
|
232
|
+
- `config.cts` — env var parsing and defaults, including `FLOTILLA_AGENT`/
|
|
233
|
+
`FLOTILLA_CODEX_BIN`/`FLOTILLA_CODEX_EXTRA_ARGS` (`tests/config.test.cts`).
|
|
234
|
+
- `sandbox.cts` — command construction (flags, env-via-stdin, credential-helper
|
|
235
|
+
argv hygiene) against an injected fake `CommandRunner`, plus `isDockerAvailable()`
|
|
236
|
+
degrading to `false` instead of throwing when Docker is absent (`tests/sandbox.test.cts`).
|
|
237
|
+
- `adapters/index.cts#selectAdapter` — picks the right `AgentAdapter` for every
|
|
238
|
+
`FLOTILLA_AGENT` value, including the claude-code fallback for an unrecognized
|
|
239
|
+
one (`tests/adapters/index.test.cts`).
|
|
240
|
+
- `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
|
|
241
|
+
and the docker-exec path (fake `CommandRunner`), and usage/report-text
|
|
242
|
+
attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
|
|
243
|
+
|
|
244
|
+
Not exercised by unit tests, and needing a real machine per the WP-07 DoD
|
|
245
|
+
("on a real machine: register → claim a seeded docs task → session runs
|
|
246
|
+
Claude Code headless → PR opens on GitHub → complete lands with evidence;
|
|
247
|
+
secrets absent from disk after exit"):
|
|
248
|
+
|
|
249
|
+
- Actually running `claude -p` headless and confirming its real
|
|
250
|
+
`--output-format json` shape (WP-13). `adapters/claude.cts` now appends
|
|
251
|
+
`--output-format json` by default and best-effort parses stdout as a single
|
|
252
|
+
JSON result object shaped like `{ type, subtype, is_error, result,
|
|
253
|
+
session_id, total_cost_usd, usage: { input_tokens, output_tokens,
|
|
254
|
+
cache_creation_input_tokens, cache_read_input_tokens } }` (see
|
|
255
|
+
`exit-conditions.cts#parseClaudeJsonResult` for the exact assumption and
|
|
256
|
+
fallback behavior). Unit tests (`tests/adapter.test.cts`,
|
|
257
|
+
`tests/exit-conditions.test.cts`) cover the parsing and arg-construction
|
|
258
|
+
logic against fixed JSON strings; what's untested offline is whether a real
|
|
259
|
+
`claude -p --output-format json` invocation actually produces this shape.
|
|
260
|
+
If it doesn't, parsing degrades to `null` and `tokensIn`/`tokensOut`/
|
|
261
|
+
`costUsd` fall back to 0 (session.cts's existing default) — never a thrown
|
|
262
|
+
error — but cost/spend data silently goes missing until the shape is
|
|
263
|
+
reconciled here.
|
|
264
|
+
- **Everything about the Codex CLI adapter (`adapters/codex.cts`) — there is
|
|
265
|
+
no `codex` binary to test against here at all, unlike Claude Code where at
|
|
266
|
+
least the flag name and general headless-JSON shape are documented.** Every
|
|
267
|
+
one of the following is an unverified guess, each flagged "ASSUMED — CONFIRM
|
|
268
|
+
AGAINST A REAL codex BINARY" at its point of use:
|
|
269
|
+
- That `codex exec "<prompt>"` is the right non-interactive/headless
|
|
270
|
+
invocation at all (the `claude -p` analog).
|
|
271
|
+
- That `--json` is a real flag and that it switches output to
|
|
272
|
+
newline-delimited JSON "event" objects (`{"id": "...", "msg": {"type":
|
|
273
|
+
..., ...}}`) rather than, say, a single JSON object like Claude's
|
|
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
|
+
`FLOTILLA_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.
|
|
302
|
+
- The full Docker sandbox lifecycle against a real Docker daemon (rootless
|
|
303
|
+
behavior, tmpfs env injection, git clone with a real PAT, `docker exec`
|
|
304
|
+
timeout/kill semantics under `AbortSignal`).
|
|
305
|
+
- The three assumed endpoints above, once they exist.
|
|
306
|
+
- Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
|
|
307
|
+
already-reassigned lease) — `session.cts` aborts the running adapter
|
|
308
|
+
process and still attempts a best-effort `complete()` call, which the
|
|
309
|
+
real server may reject; that path is untested against real semantics.
|
|
310
|
+
- Transcript upload against a real signed Supabase Storage URL.
|
|
311
|
+
- The end-to-end "Connect an agent to a project" flow against a real
|
|
312
|
+
Supabase instance: an owner minting a token from `ConnectAgentPanel`
|
|
313
|
+
(or the onboarding wizard's Agent step), `navarch-runtime connect`
|
|
314
|
+
redeeming it, and the resulting machine actually claiming a task scoped to
|
|
315
|
+
that one project. Unit-tested here: `connectMachine()`'s request shape
|
|
316
|
+
(`tests/api.test.cts`) and the `connect` command's flag/env parsing
|
|
317
|
+
(`tests/cli.test.cts`); not tested here: the real Postgres round trip
|
|
318
|
+
(`lib/flotilla/__tests__/enrollment.test.ts` and the
|
|
319
|
+
`app/api/machines/connect` / `app/api/projects/[id]/enrollment-tokens`
|
|
320
|
+
route tests cover that with a mocked admin client, not a live database).
|
|
321
|
+
- `@sagentlab/navarch-runtime` is not yet published to npm — every `npx
|
|
322
|
+
@sagentlab/navarch-runtime ...` command shown above (and in
|
|
323
|
+
`ConnectAgentPanel`) currently requires the from-source flow instead
|
|
324
|
+
(`git clone` + `./install.sh` + `node bin/navarch.cjs connect ...`).
|
|
325
|
+
|
|
326
|
+
## A note on `.cts` instead of `.ts`
|
|
327
|
+
|
|
328
|
+
Every source and test file in this package uses the `.cts` extension rather
|
|
329
|
+
than `.ts`. This isn't a style preference: the repo root's `tsconfig.json`
|
|
330
|
+
globs `**/*.ts` / `**/*.mts` into the Next.js app's own `next build`
|
|
331
|
+
type-check, and that check runs across the whole matched file set, not just
|
|
332
|
+
files reachable from a page — confirmed empirically while building this
|
|
333
|
+
package (a single stray `.ts` file under `runtime/` failed the root
|
|
334
|
+
`next build`). WP-07's file ownership is scoped to `runtime/` only, so
|
|
335
|
+
editing the root `tsconfig.json`'s `include`/`exclude` was out of bounds.
|
|
336
|
+
`.cts` is a first-class TypeScript/Node extension (forces CommonJS output,
|
|
337
|
+
still allows normal `import`/`export` source syntax) that the root glob does
|
|
338
|
+
not match, making it a clean, zero-touch way to keep this package fully
|
|
339
|
+
isolated. Vite/Vitest's default esbuild transform filter also excludes
|
|
340
|
+
`.cts` by default — `vitest.config.cts` overrides it (`esbuild.include`) so
|
|
341
|
+
tests are actually type-stripped and run.
|
|
342
|
+
|
|
343
|
+
## Discovered scope (flagging, not editing the plan doc)
|
|
344
|
+
|
|
345
|
+
- Machine registration, machine-level heartbeat, and the transcript
|
|
346
|
+
upload-URL endpoint are not in `schema-design.md` §7's API surface list —
|
|
347
|
+
see "Assumptions to confirm" above. Recommend WP-01/WP-04/WP-05 add these
|
|
348
|
+
three routes explicitly to the schema doc once designed.
|
|
349
|
+
- No admin-console UI exists yet to actually mint an `enrollment_token` for
|
|
350
|
+
`register` to redeem — that's WP-08 (Fleet view) / WP-01 (admin console)
|
|
351
|
+
territory; this package assumes it will exist.
|
package/bin/navarch.cjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Thin npx-able entry point. The real CLI lives in src/cli.cts, compiled to
|
|
5
|
+
// dist/cli.cjs by `npm run build`. Kept deliberately tiny (no logic) so there
|
|
6
|
+
// is nothing here to type-check or unit-test beyond "does it call main()".
|
|
7
|
+
require("../dist/cli.cjs")
|
|
8
|
+
.main()
|
|
9
|
+
.catch((err) => {
|
|
10
|
+
console.error(err);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
});
|
package/dist/adapter.cjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.claudeCodeAdapter = exports.runClaudeCodeAdapter = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Backward-compat re-export shim.
|
|
6
|
+
*
|
|
7
|
+
* The Claude Code adapter that used to live entirely in this file now lives
|
|
8
|
+
* in adapters/claude.cts, implementing the generalized `AgentAdapter`
|
|
9
|
+
* interface (adapters/types.cts) alongside adapters/codex.cts's Codex CLI
|
|
10
|
+
* sibling — session.cts picks between the two via FLOTILLA_AGENT
|
|
11
|
+
* (config.cts's `agentType`) through adapters/index.cts#selectAdapter.
|
|
12
|
+
*
|
|
13
|
+
* This file is kept, unchanged in its exported names, so any existing
|
|
14
|
+
* `import { runClaudeCodeAdapter } from "./adapter.cjs"` (or
|
|
15
|
+
* `import type { RunClaudeCodeOptions } from "./adapter.cjs"`) continues to
|
|
16
|
+
* resolve without a call-site change. New code should import from
|
|
17
|
+
* "./adapters/claude.cjs" (or "./adapters/index.cjs" for the
|
|
18
|
+
* agent-agnostic selection helper) directly instead.
|
|
19
|
+
*/
|
|
20
|
+
var claude_cjs_1 = require("./adapters/claude.cjs");
|
|
21
|
+
Object.defineProperty(exports, "runClaudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.runClaudeCodeAdapter; } });
|
|
22
|
+
Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.claudeCodeAdapter = void 0;
|
|
4
|
+
exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
7
|
+
/**
|
|
8
|
+
* Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
|
|
9
|
+
* WP-07): `claude -p "<context bundle>" --mcp-config platform-mcp.json
|
|
10
|
+
* --output-format json`. Returns raw stdout/stderr/exit signals (plus
|
|
11
|
+
* best-effort tokensIn/tokensOut/costUsd parsed from the JSON result -- see
|
|
12
|
+
* exit-conditions.cts#parseClaudeJsonResult) for exit-conditions.cts to map
|
|
13
|
+
* onto a complete()/fail() call.
|
|
14
|
+
*
|
|
15
|
+
* This is one of two implementations of the AgentAdapter interface
|
|
16
|
+
* (adapters/types.cts) — see adapters/codex.cts for the Codex CLI sibling
|
|
17
|
+
* session.cts picks between via FLOTILLA_AGENT (config.cts's `agentType`).
|
|
18
|
+
*
|
|
19
|
+
* NEEDS LIVE VERIFICATION (WP-13): `--output-format json` is assumed to make
|
|
20
|
+
* `claude -p` print a single JSON result object with a `usage` block, per
|
|
21
|
+
* Claude Code's documented headless-mode output. This cannot be confirmed
|
|
22
|
+
* without a real `claude` binary in this offline build environment. If the
|
|
23
|
+
* real shape differs, parseClaudeJsonResult() returns null and
|
|
24
|
+
* tokensIn/tokensOut/costUsd fall back to 0 (session.cts's existing
|
|
25
|
+
* default) rather than throwing -- see runtime/README.md.
|
|
26
|
+
*/
|
|
27
|
+
async function runClaudeCodeAdapter(options) {
|
|
28
|
+
const args = ["-p", options.prompt];
|
|
29
|
+
if (options.mcpConfigPath) {
|
|
30
|
+
args.push("--mcp-config", options.mcpConfigPath);
|
|
31
|
+
}
|
|
32
|
+
// Only append the default when the caller hasn't already asked for a
|
|
33
|
+
// specific --output-format (extraArgs wins so an operator can opt back
|
|
34
|
+
// into plain-text output, e.g. while diagnosing a live-verification
|
|
35
|
+
// mismatch, without an adapter code change).
|
|
36
|
+
if (!options.extraArgs.includes("--output-format")) {
|
|
37
|
+
args.push("--output-format", "json");
|
|
38
|
+
}
|
|
39
|
+
args.push(...options.extraArgs);
|
|
40
|
+
const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
|
|
41
|
+
return attachUsage(raw);
|
|
42
|
+
}
|
|
43
|
+
/** 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). */
|
|
44
|
+
function attachUsage(result) {
|
|
45
|
+
const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
|
|
46
|
+
if (!parsed)
|
|
47
|
+
return result;
|
|
48
|
+
const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
|
|
49
|
+
return { ...result, tokensIn: usage.tokensIn, tokensOut: usage.tokensOut, costUsd: usage.costUsd };
|
|
50
|
+
}
|
|
51
|
+
async function runOnHost(options, args) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let stdout = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
let killedByLeaseLoss = false;
|
|
57
|
+
const child = (0, node_child_process_1.spawn)(options.bin, args, {
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
env: { ...process.env, ...options.env },
|
|
60
|
+
});
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
child.kill("SIGKILL");
|
|
64
|
+
}, options.timeoutMs);
|
|
65
|
+
const onAbort = () => {
|
|
66
|
+
killedByLeaseLoss = true;
|
|
67
|
+
child.kill("SIGKILL");
|
|
68
|
+
};
|
|
69
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
child.stdout.on("data", (d) => {
|
|
71
|
+
stdout += d.toString();
|
|
72
|
+
});
|
|
73
|
+
child.stderr.on("data", (d) => {
|
|
74
|
+
stderr += d.toString();
|
|
75
|
+
});
|
|
76
|
+
child.on("error", (err) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
79
|
+
stderr += `\n${String(err)}`;
|
|
80
|
+
resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
81
|
+
});
|
|
82
|
+
child.on("close", (code) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
85
|
+
resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function runViaDocker(options, args) {
|
|
90
|
+
const { containerName, runner } = options.dockerExec;
|
|
91
|
+
const quoted = [options.bin, ...args].map(shellQuote).join(" ");
|
|
92
|
+
const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
|
|
93
|
+
let killedByLeaseLoss = false;
|
|
94
|
+
const onAbort = () => {
|
|
95
|
+
killedByLeaseLoss = true;
|
|
96
|
+
// Best-effort: kill the exec'd process inside the container. The runner
|
|
97
|
+
// call below still resolves once `docker exec` itself is torn down by
|
|
98
|
+
// its own timeout/kill; a true "kill this docker exec now" requires
|
|
99
|
+
// process-group tracking that nodeCommandRunner does not yet expose.
|
|
100
|
+
runner.run("docker", ["kill", containerName]).catch(() => undefined);
|
|
101
|
+
};
|
|
102
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
try {
|
|
104
|
+
const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
|
|
105
|
+
timeoutMs: options.timeoutMs,
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
exitCode: result.code,
|
|
109
|
+
timedOut: false,
|
|
110
|
+
killedByLeaseLoss,
|
|
111
|
+
stdout: result.stdout,
|
|
112
|
+
stderr: result.stderr,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
exitCode: null,
|
|
118
|
+
timedOut: false,
|
|
119
|
+
killedByLeaseLoss,
|
|
120
|
+
stdout: "",
|
|
121
|
+
stderr: String(err),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function shellQuote(value) {
|
|
129
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
130
|
+
}
|
|
131
|
+
/** The AgentAdapter (adapters/types.cts) wrapper session.cts selects via FLOTILLA_AGENT=claude-code (the default). */
|
|
132
|
+
exports.claudeCodeAdapter = {
|
|
133
|
+
agentType: "claude-code",
|
|
134
|
+
run: runClaudeCodeAdapter,
|
|
135
|
+
};
|