@ours.network/fleet 0.5.1 → 0.6.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 +120 -13
- package/dist/briefing.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +44 -7
- package/dist/config.js +12 -0
- package/dist/doctor.js +17 -2
- package/dist/harness/claude-code.js +1 -1
- package/dist/harness/codex.d.ts +4 -0
- package/dist/harness/codex.js +223 -0
- package/dist/harness/types.d.ts +5 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/isolation/policy.js +12 -3
- package/dist/isolation/types.d.ts +4 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +18 -0
- package/dist/runner.js +6 -1
- package/dist/spawn.d.ts +8 -0
- package/dist/spawn.js +34 -4
- package/dist/supervisor/systemd.d.ts +7 -0
- package/dist/supervisor/systemd.js +13 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,14 +26,15 @@ reality match the file.
|
|
|
26
26
|
|
|
27
27
|
**Harness-agnostic by design.** The core never assumes a specific agent CLI; each
|
|
28
28
|
harness is a small adapter (how to launch, how to resume, how to wire config).
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
**Claude Code** and **Codex CLI** are wired in, and the adapter interface is
|
|
30
|
+
public — each additional harness (Gemini CLI, OpenCode, …) is a small adapter.
|
|
31
|
+
A single fleet can mix harnesses per role:
|
|
31
32
|
|
|
32
33
|
```yaml
|
|
33
34
|
roles:
|
|
34
35
|
Reviewer: # runs in Claude Code
|
|
35
36
|
harness: claude-code
|
|
36
|
-
Prototyper:
|
|
37
|
+
Prototyper: # runs in Codex CLI
|
|
37
38
|
harness: codex
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -69,7 +70,7 @@ The state dir contract:
|
|
|
69
70
|
|---|---|---|
|
|
70
71
|
| Node ≥ 20 | runs `ours-fleet` itself | nodejs.org, `apt`, or `brew` |
|
|
71
72
|
| tmux | every role's console | `apt install tmux` / `brew install tmux` |
|
|
72
|
-
| a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) |
|
|
73
|
+
| a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
|
|
73
74
|
| `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
|
|
74
75
|
|
|
75
76
|
Linux only: `ours-fleet init` enables *linger* so roles run without a login session
|
|
@@ -106,6 +107,11 @@ From the shell:
|
|
|
106
107
|
ours-fleet spawn Worker --mission "own the worker repo" \
|
|
107
108
|
--bio-file bio.md --persona-file persona.md --coordinator FleetCoordinator
|
|
108
109
|
ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/reboot
|
|
110
|
+
|
|
111
|
+
# Codex role: ours-codex is preferred automatically; plain codex is the fallback
|
|
112
|
+
ours-fleet spawn Coder --harness codex --model gpt-5.4 \
|
|
113
|
+
--permission-mode on-request --sandbox workspace-write \
|
|
114
|
+
--profile fleet --search --monitor --coordinator FleetCoordinator
|
|
109
115
|
```
|
|
110
116
|
|
|
111
117
|
Permanent spawns are written to `~/fleet.d/<Name>.yaml` — your hand-written
|
|
@@ -146,7 +152,7 @@ ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
|
|
|
146
152
|
ours-fleet config [-c FILE] validate + print merged plan
|
|
147
153
|
ours-fleet ls | attach | peek | logs [-f] | status <Name>
|
|
148
154
|
ours-fleet send <Name> "text" | --key <K>
|
|
149
|
-
ours-fleet spawn [--temp] <Name> [--mission --model --
|
|
155
|
+
ours-fleet spawn [--temp] <Name> [--harness --mission --model --permission-mode ...]
|
|
150
156
|
ours-fleet rm <Name>
|
|
151
157
|
ours-fleet doctor [--harness H]
|
|
152
158
|
ours-fleet init
|
|
@@ -178,6 +184,18 @@ roles:
|
|
|
178
184
|
# mem_palace: false # claude-code: disable memory plugin
|
|
179
185
|
# permission_mode: dontAsk # claude-code: launch permission mode —
|
|
180
186
|
# one of default | acceptEdits | plan | dontAsk | bypassPermissions
|
|
187
|
+
# sandbox: workspace-write # codex: --sandbox —
|
|
188
|
+
# one of read-only | workspace-write | danger-full-access
|
|
189
|
+
# approval: never # codex: --ask-for-approval —
|
|
190
|
+
# one of untrusted | on-request | never
|
|
191
|
+
# permission_mode: never # codex alias for approval
|
|
192
|
+
# launcher: auto # codex: auto | ours-codex | codex
|
|
193
|
+
# profile: fleet # codex: $CODEX_HOME/fleet.config.toml
|
|
194
|
+
# search: true # codex: enable --search (live web search)
|
|
195
|
+
# add_dirs: [/data/shared] # codex: repeat --add-dir
|
|
196
|
+
# monitor: true # explicit persistent consent to arm mail wake
|
|
197
|
+
# config: # codex: repeat --config key=<TOML value>
|
|
198
|
+
# model_reasoning_effort: high
|
|
181
199
|
isolation: # OS-level sandbox (additive; omit = today's behavior)
|
|
182
200
|
backend: auto # auto | bubblewrap | podman | none (default auto)
|
|
183
201
|
on_unavailable: warn # warn (un-isolated + marker) | strict (refuse) (default warn)
|
|
@@ -189,7 +207,65 @@ roles:
|
|
|
189
207
|
|
|
190
208
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
|
191
209
|
error naming both files. Identities and roles are decoupled — removing a role
|
|
192
|
-
never deletes an identity.
|
|
210
|
+
never deletes an identity. `defaults.harness_options` is shallow-merged with each
|
|
211
|
+
role's `harness_options`, so a fleet can set common Codex permission/profile defaults
|
|
212
|
+
and override individual keys per role.
|
|
213
|
+
|
|
214
|
+
## Codex roles
|
|
215
|
+
|
|
216
|
+
Install Codex and the native ours plugin once on the fleet host:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
npm i -g @ours.network/codex
|
|
220
|
+
ours-codex-install
|
|
221
|
+
ours-fleet doctor --harness codex
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A role with `harness: codex` resolves its launcher at every supervised start:
|
|
225
|
+
|
|
226
|
+
1. `harness_options.launcher: auto` (the default) uses `ours-codex` when it is on
|
|
227
|
+
`PATH`, giving the role session-scoped background mail wake.
|
|
228
|
+
2. If `ours-codex` is absent, it launches ordinary `codex`; the native ours plugin's
|
|
229
|
+
blocking `foreground_monitor` is used as the supported fallback.
|
|
230
|
+
3. `launcher: ours-codex` makes the enhanced launcher mandatory and fails clearly if
|
|
231
|
+
it is missing; `launcher: codex` explicitly selects standard mode.
|
|
232
|
+
|
|
233
|
+
Monitoring remains consent-first. By default the generated briefing asks in the role's
|
|
234
|
+
console before calling `arm_monitor`. Set `harness_options.monitor: true` (or pass
|
|
235
|
+
`ours-fleet spawn --monitor`) to record explicit persistent consent for that role, including
|
|
236
|
+
supervised restarts. In the standard-Codex fallback, the agent separately surfaces the
|
|
237
|
+
`ours-codex` recommendation before asking to enter `foreground_monitor`. It never backgrounds
|
|
238
|
+
`ours-mcp watch`, because a detached watch cannot wake a Codex turn. The foreground
|
|
239
|
+
wait is re-entered after each handled message; `ours-codex` instead wakes the idle
|
|
240
|
+
session through its App Server integration.
|
|
241
|
+
|
|
242
|
+
The main Codex controls needed by fleet roles are available declaratively and when spawning:
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
defaults:
|
|
246
|
+
harness: codex
|
|
247
|
+
model: gpt-5.4
|
|
248
|
+
harness_options:
|
|
249
|
+
launcher: auto
|
|
250
|
+
profile: fleet # $CODEX_HOME/fleet.config.toml
|
|
251
|
+
sandbox: workspace-write
|
|
252
|
+
approval: on-request
|
|
253
|
+
monitor: true # explicit consent for unattended mail wake
|
|
254
|
+
search: true
|
|
255
|
+
add_dirs: [/data/shared]
|
|
256
|
+
config:
|
|
257
|
+
model_reasoning_effort: high
|
|
258
|
+
|
|
259
|
+
roles:
|
|
260
|
+
Reviewer:
|
|
261
|
+
harness_options:
|
|
262
|
+
sandbox: read-only # overrides just this default key
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Equivalent one-off/permanent spawn controls include `--model`, `--permission-mode`,
|
|
266
|
+
`--sandbox`, `--profile`, `--launcher`, `--search`, `--monitor`, repeatable
|
|
267
|
+
`--codex-config key=value`, and repeatable `--add-dir`. Use `env.OURS_PORT`/`env.OURS_CONFIG` for a
|
|
268
|
+
role-specific ours daemon, or configure the host default in `~/.ours/config.json`.
|
|
193
269
|
|
|
194
270
|
## Agent isolation
|
|
195
271
|
|
|
@@ -211,6 +287,8 @@ all invisible, ours messaging still works, no hard resource caps.
|
|
|
211
287
|
unshares the network; `broker` keeps host networking so the loopback ours daemon
|
|
212
288
|
stays reachable — full broker egress-hardening is a follow-up.
|
|
213
289
|
- **`fs.read` / `fs.write`** — extra read-only / read-write binds on top of the durable set.
|
|
290
|
+
The durable harness credentials are selected automatically: `~/.claude*` for Claude Code,
|
|
291
|
+
or `~/.codex` plus read-only `~/.agents` for Codex.
|
|
214
292
|
- **`resources`** — `mem` (→ `MemoryMax` + `MemorySwapMax=0`, a hard OOM bound),
|
|
215
293
|
`cpu` cores (→ `CPUQuota`), `pids` (→ `TasksMax`). CPU degrades to a warning if the
|
|
216
294
|
cpu cgroup controller isn't delegated (mem/pids still enforced).
|
|
@@ -232,15 +310,40 @@ npm run build
|
|
|
232
310
|
|
|
233
311
|
Adding a harness = implementing `HarnessAdapter`
|
|
234
312
|
(`src/harness/types.ts`) and registering it — see `src/harness/claude-code.ts`
|
|
235
|
-
for
|
|
313
|
+
and `src/harness/codex.ts` for reference implementations.
|
|
314
|
+
|
|
315
|
+
**Codex CLI resume note.** Codex assigns its own session id (there's no `--session-id`
|
|
316
|
+
equivalent to pin at creation), so resume uses `codex resume --last`, which
|
|
317
|
+
picks the most recently active session **in the role's `cwd`**. This works
|
|
318
|
+
cleanly as long as each role has its own `cwd` (the common case); two roles
|
|
319
|
+
sharing an identical `cwd` could have their resumes cross — give them distinct
|
|
320
|
+
working directories if that matters. MCP and monitor wiring is provided by
|
|
321
|
+
[`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
|
|
322
|
+
`ours-fleet doctor --harness codex` verifies the CLI, plugin, and enhanced launcher/fallback.
|
|
323
|
+
|
|
324
|
+
## Learn more
|
|
325
|
+
|
|
326
|
+
- **The AI fleet use case:** a walkthrough of the coordinator-plus-specialists
|
|
327
|
+
pattern, end to end →
|
|
328
|
+
**[ours.network/use-cases/ai-fleet](https://ours.network/use-cases/ai-fleet)**.
|
|
329
|
+
- **How it works — the protocol, in depth:** the shared agent-to-agent core and
|
|
330
|
+
wire format is documented in
|
|
331
|
+
**[ours-mufl-core](https://github.com/adapt-toolkit/ours-mufl-core)**.
|
|
332
|
+
- **The whole project:** [ours.network](https://ours.network) ·
|
|
333
|
+
[umbrella repo](https://github.com/adapt-toolkit/ours-network)
|
|
236
334
|
|
|
237
335
|
## Support ours.network
|
|
238
336
|
|
|
239
|
-
ours
|
|
240
|
-
|
|
241
|
-
at
|
|
337
|
+
ours.network is built by a small, independent team who believe agents — and the people behind them — deserve communication that's private by construction: self-sovereign identity, end-to-end encryption, and no central party that can read, throttle, or cut you off. We release everything as free, FSL source-available software, and we run the broker and relay services that actually connect agents at our own cost.
|
|
338
|
+
|
|
339
|
+
We're at the alpha stage: we have a clear roadmap and, if this stage proves itself, proper funding will come later — but right now there is no funding and no monetization behind the project. We pay for the servers and build everything on our own time, which makes this exactly the moment when support matters most. Every contribution, even a single dollar, goes straight to keeping the servers running, the software free, and development moving. If ours.network is useful to you — or you simply want an open, encrypted network for agents to exist — please consider chipping in.
|
|
340
|
+
|
|
341
|
+
**Like it? Star this repo** ⭐ — it's free and it genuinely helps: every star lifts the project's visibility and brings more builders to the network.
|
|
342
|
+
|
|
242
343
|
**→ https://github.com/adapt-toolkit/ours-donate**
|
|
243
344
|
|
|
345
|
+
Thank you for helping keep it free, open, and alive.
|
|
346
|
+
|
|
244
347
|
## Licence, status & warranty
|
|
245
348
|
|
|
246
349
|
> **Alpha software.** ours-fleet is part of **ours.network**, which is early,
|
|
@@ -266,8 +369,12 @@ separate **commercial licence** from Adapt Framework Solutions Ltd — see
|
|
|
266
369
|
[`COMMERCIAL-LICENCE.md`](COMMERCIAL-LICENCE.md) (contact:
|
|
267
370
|
**license@adaptframework.solutions**).
|
|
268
371
|
|
|
269
|
-
ours.network
|
|
270
|
-
|
|
271
|
-
|
|
372
|
+
**Built on Adapt.** ours.network runs on ADAPT, a framework we've spent eight years building. ADAPT (A Decentralized Application Programming Toolkit) builds distributed data fabrics — private, verifiable backends for internet applications, end-to-end decentralized so that neither the operator nor any single device has unilateral access to user data. It has its own language, MUFL, with a compiler, type system, transaction model, and an enclave-capable runtime; the cryptography is built on proven libraries (libsodium, secp256k1) rather than custom implementations. Architecture, language and SDK reference: [docs.adaptframework.solutions](https://docs.adaptframework.solutions).
|
|
373
|
+
|
|
374
|
+
**Not a black box.** Much of the stack is already open and inspectable. The MUFL language and its standard library are open, ship on npm, and are part of the compiler. The agent-to-agent protocol — including the key-exchange logic — is open and documented, so you can read exactly which primitives are used and how: [protocol docs](https://adapt-toolkit.github.io/ours-mufl-core/). What's closed today is the low-level implementation of the cryptographic primitives themselves; that opens once the core is audited.
|
|
375
|
+
|
|
376
|
+
**Security by design, on three layers.** Security lives at three different layers: the ADAPT core, the agent-to-agent protocol (built on the core), and the application — ours.network's MCP server (built on the protocol). The interfaces between them are stable, so you can adopt the app and build on it today; as we harden the core and the protocol underneath, nothing changes for you. You inherit security by design instead of re-implementing it per app.
|
|
377
|
+
|
|
378
|
+
**Audit status.** The core has not yet had an independent security audit. We're raising funding to commission one from a recognized firm and prove these guarantees, and we'll open-source the full core once it passes. Until then it's source-available and documented, but not independently audited — run anything critical on it at your own risk.
|
|
272
379
|
|
|
273
380
|
Copyright 2026 Adapt Framework Solutions Ltd.
|
package/dist/briefing.js
CHANGED
|
@@ -34,14 +34,16 @@ export function generateBriefing(role, v, opts) {
|
|
|
34
34
|
: ' with a 1–2 sentence summary of your Charter above. Skip if it already matches.');
|
|
35
35
|
L.push(`5. SET your **persona** (local operating contract, never shared in invites) via`);
|
|
36
36
|
L.push(` **${v.setPersonaTool}** with the **Charter** section above, verbatim. Skip if it matches.`);
|
|
37
|
-
L.push(`6. ${v.monitorInstruction(id)}`);
|
|
37
|
+
L.push(`6. ${v.monitorInstruction(id, role)}`);
|
|
38
38
|
if (role.coordinator) {
|
|
39
39
|
L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
|
|
40
|
-
L.push(` "${role.name} online — identity '${id}' bound,
|
|
41
|
-
L.push(`8. Await messages. When the monitor wakes you
|
|
40
|
+
L.push(` "${role.name} online — identity '${id}' bound, ready."`);
|
|
41
|
+
L.push(`8. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
|
|
42
|
+
L.push(` call **${v.getMessagesTool}**, act, and reply.`);
|
|
42
43
|
}
|
|
43
44
|
else {
|
|
44
|
-
L.push(`7. Await messages. When the monitor wakes you
|
|
45
|
+
L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
|
|
46
|
+
L.push(` call **${v.getMessagesTool}**, act on them,`);
|
|
45
47
|
L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
|
|
46
48
|
L.push(` via \`tmux attach -t ${role.name}\` or by messaging "${id}".`);
|
|
47
49
|
}
|
|
@@ -67,8 +69,8 @@ export function generateBriefing(role, v, opts) {
|
|
|
67
69
|
L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
|
|
68
70
|
L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
|
|
69
71
|
L.push('', '## On restart (you run under a supervised launcher)');
|
|
70
|
-
L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true)
|
|
71
|
-
L.push(
|
|
72
|
+
L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true), then`);
|
|
73
|
+
L.push(`${v.monitorInstruction(id, role)} Then continue from your WORKLOG.`);
|
|
72
74
|
L.push('Do not blindly re-run whatever may have crashed you.');
|
|
73
75
|
L.push('', '## House rules');
|
|
74
76
|
L.push('- Never broad `rm -rf` on home/critical paths; quote globs; use explicit paths.');
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
4
4
|
import { realpathSync } from 'node:fs';
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import { VERSION } from './version.js';
|
|
7
|
-
import { agentsRoot, tmpRoot, logsRoot } from './paths.js';
|
|
7
|
+
import { agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
import { Tmux } from './tmux.js';
|
|
10
10
|
import { pickBackend } from './supervisor/index.js';
|
|
@@ -13,6 +13,10 @@ import { runOnce, runTemp } from './runner.js';
|
|
|
13
13
|
import { spawnPermanent, spawnTemp } from './spawn.js';
|
|
14
14
|
import { doctor } from './doctor.js';
|
|
15
15
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
16
|
+
import './harness/codex.js'; // registers the codex adapter
|
|
17
|
+
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
18
|
+
// --user child (supervisor commands, logs, doctor). Derive it before dispatch. (#9)
|
|
19
|
+
deriveXdgRuntimeDir();
|
|
16
20
|
const binPath = (() => { try {
|
|
17
21
|
return realpathSync(process.argv[1]);
|
|
18
22
|
}
|
|
@@ -36,6 +40,26 @@ const program = new Command()
|
|
|
36
40
|
.description('Fleet of persistent, identity-bound AI agents — harness-agnostic, tmux + systemd/launchd.')
|
|
37
41
|
.version(VERSION);
|
|
38
42
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
|
|
43
|
+
const collect = (value, previous) => [...previous, value];
|
|
44
|
+
function parseCodexConfig(values) {
|
|
45
|
+
if (!values?.length)
|
|
46
|
+
return undefined;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const raw of values) {
|
|
49
|
+
const i = raw.indexOf('=');
|
|
50
|
+
if (i < 1)
|
|
51
|
+
throw new Error(`invalid --codex-config '${raw}'; expected key=value`);
|
|
52
|
+
const key = raw.slice(0, i);
|
|
53
|
+
const value = raw.slice(i + 1);
|
|
54
|
+
if (value === 'true' || value === 'false')
|
|
55
|
+
out[key] = value === 'true';
|
|
56
|
+
else if (value.trim() !== '' && Number.isFinite(Number(value)))
|
|
57
|
+
out[key] = Number(value);
|
|
58
|
+
else
|
|
59
|
+
out[key] = value;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
39
63
|
cOpt(program.command('config').description('validate + print the merged plan (no side effects)'))
|
|
40
64
|
.action(opts => {
|
|
41
65
|
try {
|
|
@@ -50,6 +74,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
50
74
|
console.log(` cwd: ${r.cwd}`);
|
|
51
75
|
if (r.model)
|
|
52
76
|
console.log(` model: ${r.model}`);
|
|
77
|
+
if (r.harness_options && Object.keys(r.harness_options).length)
|
|
78
|
+
console.log(` options: ${JSON.stringify(r.harness_options)}`);
|
|
53
79
|
if (r.coordinator)
|
|
54
80
|
console.log(` coordinator: ${r.coordinator}`);
|
|
55
81
|
if (r.mission)
|
|
@@ -161,16 +187,27 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
161
187
|
.option('--cwd <dir>', 'working directory')
|
|
162
188
|
.option('--coordinator <name>', 'announce target')
|
|
163
189
|
.option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
|
|
190
|
+
.option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
|
|
191
|
+
.option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
|
|
192
|
+
.option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
|
|
193
|
+
.option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
|
|
194
|
+
.option('--search', 'enable Codex live web search')
|
|
195
|
+
.option('--codex-config <key=value>', 'Codex config override (repeatable)', collect, [])
|
|
196
|
+
.option('--add-dir <dir>', 'additional Codex writable directory (repeatable)', collect, [])
|
|
197
|
+
.option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
|
|
164
198
|
.option('--bio-file <file>', 'public bio (file)')
|
|
165
199
|
.option('--persona-file <file>', 'persona / operating contract (file)')
|
|
166
200
|
.action(async (name, opts) => {
|
|
167
|
-
const o = {
|
|
168
|
-
name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
|
|
169
|
-
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
170
|
-
model: opts.model,
|
|
171
|
-
bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
|
|
172
|
-
};
|
|
173
201
|
try {
|
|
202
|
+
const o = {
|
|
203
|
+
name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
|
|
204
|
+
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
205
|
+
model: opts.model,
|
|
206
|
+
permissionMode: opts.permissionMode, sandbox: opts.sandbox, profile: opts.profile,
|
|
207
|
+
launcher: opts.launcher, search: opts.search,
|
|
208
|
+
codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
|
|
209
|
+
bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
|
|
210
|
+
};
|
|
174
211
|
if (o.temp) {
|
|
175
212
|
const dir = await spawnTemp(o, binPath);
|
|
176
213
|
console.log(`spawned temp agent '${name}' (state: ${dir}; gone on exit/reboot)`);
|
package/dist/config.js
CHANGED
|
@@ -62,6 +62,17 @@ export function loadConfig(configPath) {
|
|
|
62
62
|
if (bad.length)
|
|
63
63
|
throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
|
|
64
64
|
const isolation = r.isolation ?? defaults.isolation;
|
|
65
|
+
const defaultHarnessOptions = defaults.harness_options;
|
|
66
|
+
if (defaultHarnessOptions !== undefined
|
|
67
|
+
&& (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
|
|
68
|
+
|| Array.isArray(defaultHarnessOptions)))
|
|
69
|
+
throw new ConfigError(`${base}: defaults.harness_options must be a map`);
|
|
70
|
+
const harnessOptions = defaultHarnessOptions === undefined && r.harness_options === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: {
|
|
73
|
+
...(defaultHarnessOptions ?? {}),
|
|
74
|
+
...(r.harness_options ?? {}),
|
|
75
|
+
};
|
|
65
76
|
if (isolation !== undefined) {
|
|
66
77
|
const problems = validateIsolationConfig(isolation);
|
|
67
78
|
if (problems.length)
|
|
@@ -75,6 +86,7 @@ export function loadConfig(configPath) {
|
|
|
75
86
|
identity: r.identity ?? name,
|
|
76
87
|
model: r.model ?? defaults.model,
|
|
77
88
|
max_tokens: r.max_tokens ?? defaults.max_tokens,
|
|
89
|
+
harness_options: harnessOptions,
|
|
78
90
|
isolation,
|
|
79
91
|
});
|
|
80
92
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { realExec } from './exec.js';
|
|
4
4
|
import { loadConfig } from './config.js';
|
|
5
5
|
import { getAdapter } from './harness/registry.js';
|
|
6
|
-
import { agentDir, home } from './paths.js';
|
|
6
|
+
import { agentDir, home, deriveXdgRuntimeDir } from './paths.js';
|
|
7
7
|
import { resolveIsolation } from './isolation/policy.js';
|
|
8
8
|
import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
|
|
9
9
|
/** Which cgroup-v2 controllers are delegated to this user manager (advisory). */
|
|
@@ -53,6 +53,16 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
53
53
|
detail: ok ? 'enabled (roles survive logout/reboot)'
|
|
54
54
|
: `not enabled — run: ours-fleet init (or: sudo loginctl enable-linger ${user})`,
|
|
55
55
|
});
|
|
56
|
+
// systemctl --user needs $XDG_RUNTIME_DIR/bus. The cli entry point derives
|
|
57
|
+
// it from /run/user/<uid> when possible (#9), so a failure here means the
|
|
58
|
+
// user manager itself is unreachable — sudo/su shell with linger off.
|
|
59
|
+
const xdg = deriveXdgRuntimeDir();
|
|
60
|
+
checks.push({
|
|
61
|
+
name: 'user bus', ok: !!xdg,
|
|
62
|
+
detail: xdg
|
|
63
|
+
? `XDG_RUNTIME_DIR=${xdg}`
|
|
64
|
+
: `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
|
|
65
|
+
});
|
|
56
66
|
}
|
|
57
67
|
// Isolation reporting (AC-9). Backend availability is advisory — isolation is
|
|
58
68
|
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
@@ -70,7 +80,12 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
70
80
|
checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
|
|
71
81
|
for (const r of roles.filter(r => r.isolation)) {
|
|
72
82
|
const stateDir = agentDir(r.name);
|
|
73
|
-
const policy = resolveIsolation(r.isolation, {
|
|
83
|
+
const policy = resolveIsolation(r.isolation, {
|
|
84
|
+
stateDir, runCwd: r.cwd ?? stateDir, home: home(), harness: r.harness,
|
|
85
|
+
additionalWriteDirs: r.harness === 'codex'
|
|
86
|
+
? (r.harness_options?.add_dirs ?? [])
|
|
87
|
+
: [],
|
|
88
|
+
});
|
|
74
89
|
const caps = [
|
|
75
90
|
policy.resources.mem && `mem=${policy.resources.mem}`,
|
|
76
91
|
policy.resources.cpu && `cpu=${policy.resources.cpu}`,
|
|
@@ -96,7 +96,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
96
96
|
const argv = mode === 'fresh'
|
|
97
97
|
? [...base, '--session-id', s.sessionId, `Read and follow ${join(stateDir, 'briefing.md')} now.`]
|
|
98
98
|
: [...base, '--resume', s.sessionId,
|
|
99
|
-
this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'))];
|
|
99
|
+
this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
|
|
100
100
|
return { argv, env: prep.env };
|
|
101
101
|
},
|
|
102
102
|
vocabulary: {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { agentDir } from '../paths.js';
|
|
3
|
+
import { realExec } from '../exec.js';
|
|
4
|
+
import { registerAdapter } from './registry.js';
|
|
5
|
+
const OPTION_KEYS = [
|
|
6
|
+
'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
|
|
7
|
+
'monitor',
|
|
8
|
+
];
|
|
9
|
+
const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
|
|
10
|
+
/** Codex CLI's accepted `--sandbox` values. */
|
|
11
|
+
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
12
|
+
/** Codex CLI's accepted `--ask-for-approval` values. */
|
|
13
|
+
const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
|
|
14
|
+
/** Resolve & validate the per-role sandbox mode, throwing on an unknown value. */
|
|
15
|
+
function sandboxMode(role) {
|
|
16
|
+
const s = role.harness_options?.sandbox;
|
|
17
|
+
if (s == null)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!SANDBOX_MODES.includes(s))
|
|
20
|
+
throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
/** Resolve & validate the per-role approval policy, throwing on an unknown value. */
|
|
24
|
+
function approvalPolicy(role) {
|
|
25
|
+
const o = role.harness_options;
|
|
26
|
+
const a = o?.approval ?? o?.permission_mode;
|
|
27
|
+
if (a == null)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (!APPROVAL_POLICIES.includes(a))
|
|
30
|
+
throw new Error(`invalid harness_options.approval "${a}"; allowed: ${APPROVAL_POLICIES.join(', ')}`);
|
|
31
|
+
return a;
|
|
32
|
+
}
|
|
33
|
+
function launcherMode(role) {
|
|
34
|
+
return role.harness_options?.launcher ?? 'auto';
|
|
35
|
+
}
|
|
36
|
+
function encodeTomlValue(value) {
|
|
37
|
+
if (typeof value === 'string')
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
if (typeof value === 'boolean')
|
|
40
|
+
return String(value);
|
|
41
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
42
|
+
return String(value);
|
|
43
|
+
if (Array.isArray(value) && value.every(v => ['string', 'boolean', 'number'].includes(typeof v)))
|
|
44
|
+
return `[${value.map(encodeTomlValue).join(', ')}]`;
|
|
45
|
+
throw new Error('must be a string, finite number, boolean, or array of those values');
|
|
46
|
+
}
|
|
47
|
+
/** Flags shared by fresh launch and resume: model, sandbox, approval, search. */
|
|
48
|
+
function commonFlags(role) {
|
|
49
|
+
const o = (role.harness_options ?? {});
|
|
50
|
+
const search = o.search === true;
|
|
51
|
+
const sm = sandboxMode(role);
|
|
52
|
+
const ap = approvalPolicy(role);
|
|
53
|
+
return [
|
|
54
|
+
...(role.model ? ['--model', role.model] : []),
|
|
55
|
+
...(o.profile ? ['--profile', o.profile] : []),
|
|
56
|
+
...(sm ? ['--sandbox', sm] : []),
|
|
57
|
+
...(ap ? ['--ask-for-approval', ap] : []),
|
|
58
|
+
...(search ? ['--search'] : []),
|
|
59
|
+
...(o.add_dirs ?? []).flatMap(dir => ['--add-dir', dir]),
|
|
60
|
+
...Object.entries(o.config ?? {}).flatMap(([key, value]) => ['--config', `${key}=${encodeTomlValue(value)}`]),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
async function commandAvailable(command, exec) {
|
|
64
|
+
const r = await exec('sh', ['-c', `command -v ${command} >/dev/null 2>&1`]);
|
|
65
|
+
return r.code === 0;
|
|
66
|
+
}
|
|
67
|
+
function hasInstalledOursPlugin(output) {
|
|
68
|
+
try {
|
|
69
|
+
const value = JSON.parse(output);
|
|
70
|
+
return (value.installed ?? []).some(plugin => (plugin.name === 'ours' || (typeof plugin.pluginId === 'string' && plugin.pluginId.startsWith('ours@')))
|
|
71
|
+
&& plugin.installed === true
|
|
72
|
+
&& plugin.enabled === true);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function makeCodexAdapter(exec = realExec) {
|
|
79
|
+
return {
|
|
80
|
+
id: 'codex',
|
|
81
|
+
supportsResume: true,
|
|
82
|
+
async checkPrereqs() {
|
|
83
|
+
const [r, hasOursCodex, plugins] = await Promise.all([
|
|
84
|
+
exec('codex', ['--version']),
|
|
85
|
+
commandAvailable('ours-codex', exec),
|
|
86
|
+
exec('codex', ['plugin', 'list', '--json']),
|
|
87
|
+
]);
|
|
88
|
+
const ok = r.code === 0;
|
|
89
|
+
const hasOursPlugin = plugins.code === 0 && hasInstalledOursPlugin(plugins.stdout);
|
|
90
|
+
return {
|
|
91
|
+
ok: ok && hasOursPlugin,
|
|
92
|
+
checks: [
|
|
93
|
+
{
|
|
94
|
+
name: 'codex',
|
|
95
|
+
ok,
|
|
96
|
+
detail: ok ? r.stdout.trim() : 'codex CLI not found on PATH — install the Codex CLI and log in',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'ours-codex',
|
|
100
|
+
// Optional by design: plain Codex is the supported fallback.
|
|
101
|
+
ok: true,
|
|
102
|
+
detail: hasOursCodex
|
|
103
|
+
? 'available — fleet roles use native background mail wake'
|
|
104
|
+
: 'not found — fleet roles fall back to codex with foreground monitoring; install @ours.network/codex for background wake',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'ours plugin',
|
|
108
|
+
ok: hasOursPlugin,
|
|
109
|
+
detail: hasOursPlugin
|
|
110
|
+
? 'installed and enabled — ours tools and monitor tools are available'
|
|
111
|
+
: 'not installed — run: npm i -g @ours.network/codex && ours-codex-install',
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
validateOptions(opts) {
|
|
117
|
+
if (opts == null)
|
|
118
|
+
return [];
|
|
119
|
+
if (typeof opts !== 'object' || Array.isArray(opts))
|
|
120
|
+
return [{ path: 'harness_options', message: 'must be a map' }];
|
|
121
|
+
const errs = Object.keys(opts)
|
|
122
|
+
.filter(k => !OPTION_KEYS.includes(k))
|
|
123
|
+
.map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
|
|
124
|
+
const o = opts;
|
|
125
|
+
if (o.launcher != null && !LAUNCHERS.includes(o.launcher))
|
|
126
|
+
errs.push({ path: 'harness_options.launcher', message: `must be one of: ${LAUNCHERS.join(', ')}` });
|
|
127
|
+
if (o.sandbox != null && !SANDBOX_MODES.includes(o.sandbox))
|
|
128
|
+
errs.push({ path: 'harness_options.sandbox', message: `must be one of: ${SANDBOX_MODES.join(', ')}` });
|
|
129
|
+
if (o.approval != null && !APPROVAL_POLICIES.includes(o.approval))
|
|
130
|
+
errs.push({ path: 'harness_options.approval', message: `must be one of: ${APPROVAL_POLICIES.join(', ')}` });
|
|
131
|
+
if (o.permission_mode != null && !APPROVAL_POLICIES.includes(o.permission_mode))
|
|
132
|
+
errs.push({ path: 'harness_options.permission_mode', message: `must be one of: ${APPROVAL_POLICIES.join(', ')}` });
|
|
133
|
+
if (o.approval != null && o.permission_mode != null && o.approval !== o.permission_mode)
|
|
134
|
+
errs.push({ path: 'harness_options.permission_mode', message: 'conflicts with harness_options.approval' });
|
|
135
|
+
if (o.search != null && typeof o.search !== 'boolean')
|
|
136
|
+
errs.push({ path: 'harness_options.search', message: 'must be a boolean' });
|
|
137
|
+
if (o.monitor != null && typeof o.monitor !== 'boolean')
|
|
138
|
+
errs.push({ path: 'harness_options.monitor', message: 'must be a boolean' });
|
|
139
|
+
if (o.profile != null && (typeof o.profile !== 'string' || !o.profile.trim()))
|
|
140
|
+
errs.push({ path: 'harness_options.profile', message: 'must be a non-empty profile name' });
|
|
141
|
+
if (o.add_dirs != null && (!Array.isArray(o.add_dirs) || o.add_dirs.some(v => typeof v !== 'string' || !v)))
|
|
142
|
+
errs.push({ path: 'harness_options.add_dirs', message: 'must be an array of non-empty paths' });
|
|
143
|
+
if (o.config != null) {
|
|
144
|
+
if (typeof o.config !== 'object' || Array.isArray(o.config))
|
|
145
|
+
errs.push({ path: 'harness_options.config', message: 'must be a map of Codex config keys to TOML scalar/array values' });
|
|
146
|
+
else
|
|
147
|
+
for (const [key, value] of Object.entries(o.config)) {
|
|
148
|
+
try {
|
|
149
|
+
encodeTomlValue(value);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
errs.push({ path: `harness_options.config.${key}`, message: e.message });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return errs;
|
|
157
|
+
},
|
|
158
|
+
async prepareSession(role, _dirs) {
|
|
159
|
+
const requested = launcherMode(role);
|
|
160
|
+
const hasOursCodex = await commandAvailable('ours-codex', exec);
|
|
161
|
+
if (requested === 'ours-codex' && !hasOursCodex)
|
|
162
|
+
throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
|
|
163
|
+
const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
|
|
164
|
+
return { argv: [], env: {}, command };
|
|
165
|
+
},
|
|
166
|
+
buildLaunch(role, mode, _s, prep) {
|
|
167
|
+
const stateDir = roleStateDir(role);
|
|
168
|
+
const flags = commonFlags(role);
|
|
169
|
+
const command = prep.command ?? 'codex';
|
|
170
|
+
const argv = mode === 'fresh'
|
|
171
|
+
? [command, ...flags, ...prep.argv, `Read and follow ${join(stateDir, 'briefing.md')} now.`]
|
|
172
|
+
: [command, 'resume', '--last', ...flags, ...prep.argv,
|
|
173
|
+
this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
|
|
174
|
+
return { argv, env: prep.env };
|
|
175
|
+
},
|
|
176
|
+
vocabulary: {
|
|
177
|
+
bindTool: 'choose_identity',
|
|
178
|
+
createTool: 'create_identity',
|
|
179
|
+
setBioTool: 'set_bio',
|
|
180
|
+
setPersonaTool: 'set_persona',
|
|
181
|
+
currentIdentityTool: 'current_identity',
|
|
182
|
+
sendTool: 'send_message',
|
|
183
|
+
getMessagesTool: 'get_messages',
|
|
184
|
+
watchCommand: id => `ours-mcp watch "${id}"`,
|
|
185
|
+
monitorInstruction: (id, configuredRole) => {
|
|
186
|
+
const consented = configuredRole?.harness_options?.monitor === true;
|
|
187
|
+
const consent = consented
|
|
188
|
+
? `The fleet owner explicitly consented in configuration with \`harness_options.monitor: true\`. ` +
|
|
189
|
+
`Call **arm_monitor** for identity "${id}" after binding. `
|
|
190
|
+
: `Ask the fleet owner in this console whether to arm mail monitoring for "${id}". ` +
|
|
191
|
+
`Do not call **arm_monitor** until they explicitly say yes; if they decline, leave it ` +
|
|
192
|
+
`disarmed and check mail only when asked. `;
|
|
193
|
+
return consent +
|
|
194
|
+
`Under \`ours-codex\` this arms session-scoped background wake. If the tool reports that ` +
|
|
195
|
+
`only standard Codex is available, surface its \`ours-codex\` recommendation and ask ` +
|
|
196
|
+
`separately before calling **foreground_monitor**; that blocking wait is the supported ` +
|
|
197
|
+
`fallback. After each arrival, call **get_messages**, handle the mail, and re-enter ` +
|
|
198
|
+
`**foreground_monitor** while the approved monitoring session remains armed.`;
|
|
199
|
+
},
|
|
200
|
+
launchNote: name => `You were launched as the fleet role \`${name}\` under a Codex session. Confirm you are running.`,
|
|
201
|
+
restartPrompt: (id, worklog, configuredRole) => {
|
|
202
|
+
const consented = configuredRole?.harness_options?.monitor === true;
|
|
203
|
+
return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
|
|
204
|
+
(consented
|
|
205
|
+
? `then call arm_monitor for "${id}"; monitor consent is persisted in fleet configuration. `
|
|
206
|
+
: `then ask the fleet owner before arming monitoring for "${id}". `) +
|
|
207
|
+
`If this is the native-codex fallback, follow the tool's foreground_monitor consent flow and ` +
|
|
208
|
+
`re-enter it after handling each message. Continue from ${worklog}. Do not re-run whatever crashed you.`;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// The adapter needs the state dir for briefing/worklog paths in launch prompts.
|
|
215
|
+
// Roles' state dirs are canonical: agentDir(name) — temp roles carry their dir in cwd handling
|
|
216
|
+
// by the runner, which passes dirs to prepareSession; buildLaunch derives from the same rule.
|
|
217
|
+
function roleStateDir(role) {
|
|
218
|
+
// Temp roles are marked by the runner via a private field to keep the interface small.
|
|
219
|
+
const temp = role.__temp === true;
|
|
220
|
+
return agentDir(role.name, temp);
|
|
221
|
+
}
|
|
222
|
+
export const codexAdapter = makeCodexAdapter();
|
|
223
|
+
registerAdapter(codexAdapter);
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -15,10 +15,12 @@ export interface RoleDirs {
|
|
|
15
15
|
export interface SessionState {
|
|
16
16
|
sessionId: string;
|
|
17
17
|
}
|
|
18
|
-
/** Extra argv/env contributed by prepareSession (overlays, trust, limits). */
|
|
18
|
+
/** Extra command/argv/env contributed by prepareSession (overlays, trust, limits). */
|
|
19
19
|
export interface SessionPrep {
|
|
20
20
|
argv: string[];
|
|
21
21
|
env: Record<string, string>;
|
|
22
|
+
/** Optional launcher selected after runtime prerequisite probing. */
|
|
23
|
+
command?: string;
|
|
22
24
|
}
|
|
23
25
|
export interface Launch {
|
|
24
26
|
argv: string[];
|
|
@@ -34,9 +36,9 @@ export interface BriefingVocab {
|
|
|
34
36
|
sendTool: string;
|
|
35
37
|
getMessagesTool: string;
|
|
36
38
|
watchCommand(identity: string): string;
|
|
37
|
-
monitorInstruction(identity: string): string;
|
|
39
|
+
monitorInstruction(identity: string, role?: ResolvedRole): string;
|
|
38
40
|
launchNote(name: string): string;
|
|
39
|
-
restartPrompt(identity: string, worklogPath: string): string;
|
|
41
|
+
restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
|
|
40
42
|
}
|
|
41
43
|
export interface ExitPolicy {
|
|
42
44
|
cleanExitIsFresh: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry } from './conf
|
|
|
3
3
|
export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, RoleDirs, ValidationError, } from './harness/types.js';
|
|
4
4
|
export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
|
|
5
5
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
6
|
+
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
6
7
|
export { generateBriefing } from './briefing.js';
|
|
7
8
|
export { pickBackend } from './supervisor/index.js';
|
|
8
9
|
export type { SupervisorBackend } from './supervisor/types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { loadConfig, findRole, ConfigError } from './config.js';
|
|
2
2
|
export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
|
|
3
3
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
4
|
+
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
4
5
|
export { generateBriefing } from './briefing.js';
|
|
5
6
|
export { pickBackend } from './supervisor/index.js';
|
|
6
7
|
export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
|
package/dist/isolation/policy.js
CHANGED
|
@@ -75,11 +75,20 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
75
75
|
mounts.push({ src: p, dst: p, mode: 'rw' }); };
|
|
76
76
|
const addRo = (p) => { if (!mounts.some(m => m.src === p))
|
|
77
77
|
mounts.push({ src: p, dst: p, mode: 'ro' }); };
|
|
78
|
-
// Durable set
|
|
78
|
+
// Durable set: state dir + cwd, then only the active harness's config/auth roots.
|
|
79
79
|
addRw(stateDir);
|
|
80
80
|
addRw(runCwd);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if (ctx.harness === 'codex') {
|
|
82
|
+
addRw(join(home, '.codex'));
|
|
83
|
+
addRo(join(home, '.agents'));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Preserve the historical default for callers that predate harness-aware contexts.
|
|
87
|
+
addRw(join(home, '.claude'));
|
|
88
|
+
addRw(join(home, '.claude.json'));
|
|
89
|
+
}
|
|
90
|
+
for (const dir of ctx.additionalWriteDirs ?? [])
|
|
91
|
+
addRw(dir);
|
|
83
92
|
// Declared fs extras.
|
|
84
93
|
for (const p of cfg.fs?.write ?? [])
|
|
85
94
|
addRw(p);
|
|
@@ -46,6 +46,10 @@ export interface WrapContext {
|
|
|
46
46
|
stateDir: string;
|
|
47
47
|
runCwd: string;
|
|
48
48
|
home: string;
|
|
49
|
+
/** Harness selects the minimum credential/config mounts needed by that CLI. */
|
|
50
|
+
harness?: string;
|
|
51
|
+
/** Harness-declared writable roots (for example Codex --add-dir). */
|
|
52
|
+
additionalWriteDirs?: string[];
|
|
49
53
|
brokerEndpoint?: string;
|
|
50
54
|
}
|
|
51
55
|
/**
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/** Home root for config + state. OURS_FLEET_HOME overrides (tests, exotic setups). */
|
|
2
2
|
export declare const home: () => string;
|
|
3
|
+
/**
|
|
4
|
+
* systemctl/journalctl --user locate the user bus via $XDG_RUNTIME_DIR/bus.
|
|
5
|
+
* sudo/su shells run inside the CALLING user's logind session, so the target
|
|
6
|
+
* user's shell gets no XDG_RUNTIME_DIR even when linger keeps the user manager
|
|
7
|
+
* (and the bus socket) alive at /run/user/<uid>. Derive the standard path once
|
|
8
|
+
* at CLI startup: never override an existing value, and only fire when the dir
|
|
9
|
+
* actually exists — when it doesn't, the real problem is missing linger and the
|
|
10
|
+
* systemctl error (plus its hint) is the right signal. (#9)
|
|
11
|
+
*/
|
|
12
|
+
export declare function deriveXdgRuntimeDir(env?: NodeJS.ProcessEnv, uid?: number | undefined, exists?: (p: string) => boolean): string | undefined;
|
|
3
13
|
export declare const stateRoot: () => string;
|
|
4
14
|
export declare const agentsRoot: () => string;
|
|
5
15
|
export declare const tmpRoot: () => string;
|
package/dist/paths.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
/** Home root for config + state. OURS_FLEET_HOME overrides (tests, exotic setups). */
|
|
4
5
|
export const home = () => process.env.OURS_FLEET_HOME ?? homedir();
|
|
6
|
+
/**
|
|
7
|
+
* systemctl/journalctl --user locate the user bus via $XDG_RUNTIME_DIR/bus.
|
|
8
|
+
* sudo/su shells run inside the CALLING user's logind session, so the target
|
|
9
|
+
* user's shell gets no XDG_RUNTIME_DIR even when linger keeps the user manager
|
|
10
|
+
* (and the bus socket) alive at /run/user/<uid>. Derive the standard path once
|
|
11
|
+
* at CLI startup: never override an existing value, and only fire when the dir
|
|
12
|
+
* actually exists — when it doesn't, the real problem is missing linger and the
|
|
13
|
+
* systemctl error (plus its hint) is the right signal. (#9)
|
|
14
|
+
*/
|
|
15
|
+
export function deriveXdgRuntimeDir(env = process.env, uid = process.getuid?.(), exists = existsSync) {
|
|
16
|
+
if (!env.XDG_RUNTIME_DIR && uid !== undefined) {
|
|
17
|
+
const runDir = `/run/user/${uid}`;
|
|
18
|
+
if (exists(runDir))
|
|
19
|
+
env.XDG_RUNTIME_DIR = runDir;
|
|
20
|
+
}
|
|
21
|
+
return env.XDG_RUNTIME_DIR;
|
|
22
|
+
}
|
|
5
23
|
export const stateRoot = () => join(home(), '.ours-fleet');
|
|
6
24
|
export const agentsRoot = () => join(stateRoot(), 'agents');
|
|
7
25
|
export const tmpRoot = () => join(stateRoot(), 'tmp');
|
package/dist/runner.js
CHANGED
|
@@ -72,7 +72,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
72
72
|
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
73
73
|
let paneArgv = launch.argv;
|
|
74
74
|
if (role.isolation) {
|
|
75
|
-
const
|
|
75
|
+
const addDirs = role.harness === 'codex'
|
|
76
|
+
? (role.harness_options?.add_dirs ?? [])
|
|
77
|
+
: [];
|
|
78
|
+
const ctx = {
|
|
79
|
+
stateDir: dir, runCwd, home: home(), harness: role.harness, additionalWriteDirs: addDirs,
|
|
80
|
+
};
|
|
76
81
|
const policy = resolveIsolation(role.isolation, ctx);
|
|
77
82
|
const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
|
|
78
83
|
const degradedMarker = join(dir, '.isolation-degraded');
|
package/dist/spawn.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ export interface SpawnOpts {
|
|
|
8
8
|
cwd?: string;
|
|
9
9
|
coordinator?: string;
|
|
10
10
|
model?: string;
|
|
11
|
+
permissionMode?: string;
|
|
12
|
+
sandbox?: string;
|
|
13
|
+
profile?: string;
|
|
14
|
+
launcher?: string;
|
|
15
|
+
search?: boolean;
|
|
16
|
+
codexConfig?: Record<string, string | number | boolean>;
|
|
17
|
+
addDirs?: string[];
|
|
18
|
+
monitor?: boolean;
|
|
11
19
|
bioFile?: string;
|
|
12
20
|
personaFile?: string;
|
|
13
21
|
overseeInterval?: string;
|
package/dist/spawn.js
CHANGED
|
@@ -5,7 +5,7 @@ import { stringify } from 'yaml';
|
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { loadConfig } from './config.js';
|
|
7
7
|
import { applyRole, up } from './ops.js';
|
|
8
|
-
function roleFromOpts(o) {
|
|
8
|
+
function roleFromOpts(o, defaultHarness) {
|
|
9
9
|
const r = {};
|
|
10
10
|
if (o.harness)
|
|
11
11
|
r.harness = o.harness;
|
|
@@ -19,6 +19,26 @@ function roleFromOpts(o) {
|
|
|
19
19
|
r.mission = o.mission;
|
|
20
20
|
if (o.model?.trim())
|
|
21
21
|
r.model = o.model.trim();
|
|
22
|
+
const harness = o.harness ?? defaultHarness;
|
|
23
|
+
const harnessOptions = {};
|
|
24
|
+
if (o.permissionMode)
|
|
25
|
+
harnessOptions[harness === 'claude-code' ? 'permission_mode' : 'approval'] = o.permissionMode;
|
|
26
|
+
if (o.sandbox)
|
|
27
|
+
harnessOptions.sandbox = o.sandbox;
|
|
28
|
+
if (o.profile)
|
|
29
|
+
harnessOptions.profile = o.profile;
|
|
30
|
+
if (o.launcher)
|
|
31
|
+
harnessOptions.launcher = o.launcher;
|
|
32
|
+
if (o.search === true)
|
|
33
|
+
harnessOptions.search = true;
|
|
34
|
+
if (o.codexConfig && Object.keys(o.codexConfig).length)
|
|
35
|
+
harnessOptions.config = o.codexConfig;
|
|
36
|
+
if (o.addDirs?.length)
|
|
37
|
+
harnessOptions.add_dirs = o.addDirs;
|
|
38
|
+
if (o.monitor === true)
|
|
39
|
+
harnessOptions.monitor = true;
|
|
40
|
+
if (Object.keys(harnessOptions).length)
|
|
41
|
+
r.harness_options = harnessOptions;
|
|
22
42
|
if (o.bioFile)
|
|
23
43
|
r.bio = readFileSync(o.bioFile, 'utf8').trim();
|
|
24
44
|
if (o.personaFile)
|
|
@@ -35,9 +55,12 @@ function assertNameFree(o) {
|
|
|
35
55
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
36
56
|
export async function spawnPermanent(o, deps) {
|
|
37
57
|
assertNameFree(o);
|
|
58
|
+
const cfg = loadConfig(o.configPath);
|
|
38
59
|
mkdirSync(fleetDDir(), { recursive: true });
|
|
39
60
|
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
40
|
-
writeFileSync(file, stringify({
|
|
61
|
+
writeFileSync(file, stringify({
|
|
62
|
+
roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
|
|
63
|
+
}));
|
|
41
64
|
await up(loadConfig(o.configPath), [o.name], deps);
|
|
42
65
|
return file;
|
|
43
66
|
}
|
|
@@ -54,12 +77,19 @@ const detachedSupervisor = (binPath, args, dir) => {
|
|
|
54
77
|
export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
55
78
|
assertNameFree(o);
|
|
56
79
|
const cfg = loadConfig(o.configPath);
|
|
80
|
+
const defaultHarness = cfg.defaults.harness;
|
|
81
|
+
const fromOpts = roleFromOpts(o, defaultHarness);
|
|
82
|
+
const mergedHarnessOptions = {
|
|
83
|
+
...(cfg.defaults.harness_options ?? {}),
|
|
84
|
+
...(fromOpts.harness_options ?? {}),
|
|
85
|
+
};
|
|
57
86
|
const role = {
|
|
58
|
-
...
|
|
87
|
+
...fromOpts,
|
|
59
88
|
name: o.name,
|
|
60
|
-
harness: o.harness ??
|
|
89
|
+
harness: o.harness ?? defaultHarness ?? 'claude-code',
|
|
61
90
|
identity: o.identity ?? o.name,
|
|
62
91
|
model: o.model?.trim() || cfg.defaults.model,
|
|
92
|
+
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
63
93
|
sourceFile: '(temp)',
|
|
64
94
|
};
|
|
65
95
|
const dir = applyRole(role, { temp: true });
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
2
|
import type { SupervisorBackend } from './types.js';
|
|
3
3
|
export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
4
|
+
/**
|
|
5
|
+
* Actionable hint when systemctl cannot reach the user bus. After the cli.ts
|
|
6
|
+
* XDG_RUNTIME_DIR fallback this branch stays reachable only when
|
|
7
|
+
* /run/user/<uid> itself is missing — i.e. linger is off and no session is
|
|
8
|
+
* active — so pointing at linger is the correct first hint. (#9)
|
|
9
|
+
*/
|
|
10
|
+
export declare const busHint: (stderr: string) => string;
|
|
4
11
|
export declare const unitFor: (name: string) => string;
|
|
5
12
|
export declare function makeSystemdBackend(exec?: Exec): SupervisorBackend;
|
|
@@ -4,6 +4,16 @@ import { userInfo } from 'node:os';
|
|
|
4
4
|
import { home } from '../paths.js';
|
|
5
5
|
import { realExec } from '../exec.js';
|
|
6
6
|
export const UNIT_TEMPLATE = 'ours-fleet-agent@.service';
|
|
7
|
+
/**
|
|
8
|
+
* Actionable hint when systemctl cannot reach the user bus. After the cli.ts
|
|
9
|
+
* XDG_RUNTIME_DIR fallback this branch stays reachable only when
|
|
10
|
+
* /run/user/<uid> itself is missing — i.e. linger is off and no session is
|
|
11
|
+
* active — so pointing at linger is the correct first hint. (#9)
|
|
12
|
+
*/
|
|
13
|
+
export const busHint = (stderr) => /user scope bus|XDG_RUNTIME_DIR/.test(stderr)
|
|
14
|
+
? `\nhint: no user runtime dir — enable linger: sudo loginctl enable-linger ${userInfo().username}` +
|
|
15
|
+
`\n (if linger is already on: export XDG_RUNTIME_DIR=/run/user/$(id -u))`
|
|
16
|
+
: '';
|
|
7
17
|
export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
|
|
8
18
|
export function makeSystemdBackend(exec = realExec) {
|
|
9
19
|
const ctl = (...args) => exec('systemctl', ['--user', ...args]);
|
|
@@ -38,18 +48,18 @@ WantedBy=default.target
|
|
|
38
48
|
async install(name) {
|
|
39
49
|
const r = await ctl('enable', '--now', unitFor(name));
|
|
40
50
|
if (r.code !== 0)
|
|
41
|
-
throw new Error(`systemctl enable --now ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
51
|
+
throw new Error(`systemctl enable --now ${unitFor(name)} failed: ${r.stderr.trim()}${busHint(r.stderr)}`);
|
|
42
52
|
},
|
|
43
53
|
async start(name) { await ctl('start', unitFor(name)); },
|
|
44
54
|
async stop(name) {
|
|
45
55
|
const r = await ctl('stop', unitFor(name));
|
|
46
56
|
if (r.code !== 0)
|
|
47
|
-
throw new Error(`systemctl stop ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
57
|
+
throw new Error(`systemctl stop ${unitFor(name)} failed: ${r.stderr.trim()}${busHint(r.stderr)}`);
|
|
48
58
|
},
|
|
49
59
|
async restart(name) {
|
|
50
60
|
const r = await ctl('restart', unitFor(name));
|
|
51
61
|
if (r.code !== 0)
|
|
52
|
-
throw new Error(`systemctl restart ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
62
|
+
throw new Error(`systemctl restart ${unitFor(name)} failed: ${r.stderr.trim()}${busHint(r.stderr)}`);
|
|
53
63
|
},
|
|
54
64
|
async status(name) {
|
|
55
65
|
const r = await ctl('status', unitFor(name), '--no-pager');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|