@ours.network/fleet 0.9.4 → 0.9.7
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 +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
package/dist/docs.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable, AI-friendly CLI and configuration reference.
|
|
3
|
+
*
|
|
4
|
+
* Keep this concise enough to place directly in an agent context. Unlike
|
|
5
|
+
* Commander's per-command help, this describes how the pieces compose.
|
|
6
|
+
*/
|
|
7
|
+
export const AI_DOCS = `# ours-fleet reference
|
|
8
|
+
|
|
9
|
+
ours-fleet runs persistent or temporary, identity-bound AI roles. A role selects
|
|
10
|
+
a harness independently from its session backend:
|
|
11
|
+
|
|
12
|
+
- harness: \`claude-code\` or \`codex\`
|
|
13
|
+
- session: \`tmux\` (default) or \`acp\`
|
|
14
|
+
- lifetime: permanent (supervised, restartable) or \`spawn --temp\`
|
|
15
|
+
|
|
16
|
+
## Discover and validate
|
|
17
|
+
|
|
18
|
+
\`\`\`sh
|
|
19
|
+
ours-fleet docs # this complete reference (\`man\` is an alias)
|
|
20
|
+
ours-fleet help <command> # exact flags for one command
|
|
21
|
+
ours-fleet config [-c FILE] # validate and print the merged plan; no changes
|
|
22
|
+
ours-fleet doctor [-c FILE] [--harness codex|claude-code]
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
Default configuration is \`~/fleet.yaml\` plus sorted \`~/fleet.d/*.yaml\` role
|
|
26
|
+
drop-ins. An explicit \`-c FILE\` replaces \`~/fleet.yaml\`; fleet.d still adds
|
|
27
|
+
roles. Validate with \`config\` and \`doctor\` before starting or restarting.
|
|
28
|
+
|
|
29
|
+
## Lifecycle and console commands
|
|
30
|
+
|
|
31
|
+
\`\`\`sh
|
|
32
|
+
ours-fleet init
|
|
33
|
+
ours-fleet up|down [Name...]
|
|
34
|
+
ours-fleet restart [Name...] # preserve/resume harness context
|
|
35
|
+
ours-fleet force-restart [Name...] # fresh context; briefing is reloaded
|
|
36
|
+
ours-fleet ls
|
|
37
|
+
ours-fleet status|peek|attach|logs Name
|
|
38
|
+
ours-fleet logs -f Name
|
|
39
|
+
ours-fleet send Name "prompt"
|
|
40
|
+
ours-fleet send Name --key Enter # tmux only
|
|
41
|
+
ours-fleet rm Name
|
|
42
|
+
\`\`\`
|
|
43
|
+
|
|
44
|
+
\`peek\`, \`attach\`, and text \`send\` work with tmux and ACP. ACP attachment
|
|
45
|
+
also accepts \`/permit <permission-id> <option-id>\`, \`/interrupt\`, and
|
|
46
|
+
\`/detach\`. Raw \`--key\` input is tmux-only.
|
|
47
|
+
|
|
48
|
+
## Spawn
|
|
49
|
+
|
|
50
|
+
\`\`\`sh
|
|
51
|
+
ours-fleet spawn [--temp] Name \\
|
|
52
|
+
--harness codex|claude-code --session tmux|acp \\
|
|
53
|
+
--mission "one line" --cwd /absolute/path --identity Identity \\
|
|
54
|
+
--coordinator Coordinator --model MODEL \\
|
|
55
|
+
--approval ask|allow|deny \\
|
|
56
|
+
--filesystem read-only|workspace|unrestricted \\
|
|
57
|
+
--unattended deny|wait \\
|
|
58
|
+
--bio-file /path/bio.md --persona-file /path/persona.md
|
|
59
|
+
\`\`\`
|
|
60
|
+
|
|
61
|
+
Permanent spawn writes \`~/fleet.d/Name.yaml\` and starts a supervised role.
|
|
62
|
+
\`--temp\` writes ephemeral state, starts a detached supervisor, and removes the
|
|
63
|
+
role after exit/reboot. Both lifetimes support \`--session acp\`.
|
|
64
|
+
|
|
65
|
+
Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
|
|
66
|
+
\`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
|
|
67
|
+
\`--add-dir\`, and \`--monitor\`. Run \`ours-fleet help spawn\` for exact values.
|
|
68
|
+
|
|
69
|
+
## fleet.yaml
|
|
70
|
+
|
|
71
|
+
\`\`\`yaml
|
|
72
|
+
vars:
|
|
73
|
+
work_root: /home/me/work
|
|
74
|
+
start_stagger_ms: 0
|
|
75
|
+
defaults:
|
|
76
|
+
harness: codex
|
|
77
|
+
session: acp
|
|
78
|
+
model: gpt-model-id
|
|
79
|
+
permissions:
|
|
80
|
+
approval: ask
|
|
81
|
+
filesystem: workspace
|
|
82
|
+
unattended: deny
|
|
83
|
+
monitor:
|
|
84
|
+
enabled: true
|
|
85
|
+
roles:
|
|
86
|
+
Coordinator:
|
|
87
|
+
harness: codex
|
|
88
|
+
session: acp
|
|
89
|
+
identity: Coordinator
|
|
90
|
+
cwd: \${work_root}/project
|
|
91
|
+
mission: Coordinate work and delegate implementation.
|
|
92
|
+
model: gpt-model-id
|
|
93
|
+
permissions:
|
|
94
|
+
approval: ask
|
|
95
|
+
filesystem: workspace
|
|
96
|
+
unattended: deny
|
|
97
|
+
session_options: # advanced overrides; normally omit
|
|
98
|
+
# acp:
|
|
99
|
+
# command: [/custom/codex-acp, --flag]
|
|
100
|
+
tmux:
|
|
101
|
+
boot_grace_ms: 10000
|
|
102
|
+
monitor:
|
|
103
|
+
enabled: true
|
|
104
|
+
wake_sources: [message_received, file_received, local_contact_request, pending_message]
|
|
105
|
+
batch_ms: 2000
|
|
106
|
+
inject: notification
|
|
107
|
+
turn_fail_threshold: 3
|
|
108
|
+
harness_options:
|
|
109
|
+
launcher: auto
|
|
110
|
+
sandbox: workspace-write
|
|
111
|
+
approval: on-request
|
|
112
|
+
search: false
|
|
113
|
+
profile: fleet
|
|
114
|
+
add_dirs: [/data/shared]
|
|
115
|
+
config:
|
|
116
|
+
model_reasoning_effort: high
|
|
117
|
+
bio: Public role card and when peers should engage it.
|
|
118
|
+
persona: Local operating contract, boundaries, and escalation policy.
|
|
119
|
+
briefing_file: /absolute/custom-briefing.md
|
|
120
|
+
coordinator: AnotherCoordinator
|
|
121
|
+
env:
|
|
122
|
+
KEY: value
|
|
123
|
+
oversee:
|
|
124
|
+
- { role: Worker, interval: 5m }
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
|
|
128
|
+
Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
|
|
129
|
+
Use README.md for the complete isolation policy and resource-cap schema.
|
|
130
|
+
|
|
131
|
+
## Permissions
|
|
132
|
+
|
|
133
|
+
Prefer the harness-neutral \`permissions\` block:
|
|
134
|
+
|
|
135
|
+
- \`approval: ask|allow|deny\`: whether actions may request or receive approval
|
|
136
|
+
- \`filesystem: read-only|workspace|unrestricted\`: filesystem intent
|
|
137
|
+
- \`unattended: deny|wait\`: what ACP does when no console can answer a request
|
|
138
|
+
|
|
139
|
+
The backend translates this common intent. Harness-native settings in
|
|
140
|
+
\`harness_options\` take precedence where supplied. Do not choose
|
|
141
|
+
\`allow\`/\`unrestricted\`, Codex \`never\`/\`danger-full-access\`, or Claude
|
|
142
|
+
\`bypassPermissions\` without explicit authorization.
|
|
143
|
+
|
|
144
|
+
### Creation-time isolation
|
|
145
|
+
|
|
146
|
+
\`ours-fleet spawn --isolation-file <path>\` supplies a role's sandbox policy at
|
|
147
|
+
creation, so the FIRST launch is already confined — a role that only gains
|
|
148
|
+
\`isolation:\` on a later \`up\` ran unsandboxed until then.
|
|
149
|
+
|
|
150
|
+
The file holds exactly the \`isolation:\` mapping documented above and nothing
|
|
151
|
+
else — the same schema, validated by the same code, so a policy written here
|
|
152
|
+
cannot mean something different from the identical block in fleet.yaml:
|
|
153
|
+
|
|
154
|
+
\`\`\`yaml
|
|
155
|
+
network: deny
|
|
156
|
+
fs:
|
|
157
|
+
read: [/opt/reference]
|
|
158
|
+
resources:
|
|
159
|
+
mem: 2G
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
Invalid files are rejected before anything is created: no config, no state
|
|
163
|
+
directory, no identity reservation. Works for both permanent and \`--temp\` roles.
|
|
164
|
+
|
|
165
|
+
### Never-prompt failure
|
|
166
|
+
|
|
167
|
+
The failure this section exists to prevent leaves no error message anywhere.
|
|
168
|
+
|
|
169
|
+
An unattended role has no console. When the harness needs a permission decision
|
|
170
|
+
there is nobody to ask, so the request is refused INSIDE the harness — no
|
|
171
|
+
prompt, no error, no log line. The agent simply does less than its briefing told
|
|
172
|
+
it to, reports success, and nothing distinguishes that from having done the
|
|
173
|
+
work. Two settings produce it:
|
|
174
|
+
|
|
175
|
+
1. a permission mode that suppresses the prompt without granting the action
|
|
176
|
+
(Claude \`dontAsk\`, which is why neutral \`allow\` maps to
|
|
177
|
+
\`bypassPermissions\` instead); and
|
|
178
|
+
2. \`unattended: deny\`, which refuses every request that reaches it.
|
|
179
|
+
|
|
180
|
+
**Automatic decisions are now recorded.** Every permission request decided
|
|
181
|
+
without a human emits a completed event into
|
|
182
|
+
\`~/.ours-fleet/agents/<Name>/.session-events.jsonl\` carrying the decision,
|
|
183
|
+
whether policy or a person made it, the policy that produced it
|
|
184
|
+
(\`permissions.unattended=deny\` vs \`permissions.approval=deny\`/\`=allow\`),
|
|
185
|
+
the reason, and the option selected. \`ours-fleet peek\` and \`attach\` render
|
|
186
|
+
them. Automatic denial asks for a one-shot rejection, never a standing one, so a
|
|
187
|
+
single unattended refusal cannot disable a tool for the rest of the session.
|
|
188
|
+
|
|
189
|
+
A role that can auto-deny logs one line at startup saying so.
|
|
190
|
+
|
|
191
|
+
To detect an under-permissioned role BEFORE it runs, use the capability floor
|
|
192
|
+
below: \`ours-fleet doctor\` fails such a role rather than letting it discover
|
|
193
|
+
the problem silently at work.
|
|
194
|
+
|
|
195
|
+
### The unattended capability floor
|
|
196
|
+
|
|
197
|
+
An unattended role has no console, so a permission request cannot be answered —
|
|
198
|
+
it is refused, silently, inside the harness. The agent then does less than it
|
|
199
|
+
was told to and reports no error. To make that visible before launch,
|
|
200
|
+
\`ours-fleet config\` and \`ours-fleet doctor\` resolve each role's neutral
|
|
201
|
+
permissions through its harness and check the result against a fixed floor:
|
|
202
|
+
|
|
203
|
+
- \`read-state\` — read its briefing, ROUTINES.md, and WORKLOG.md
|
|
204
|
+
- \`write-state\` — append its WORKLOG and its own state files
|
|
205
|
+
- \`messaging\` — bind its identity, send and receive ours mail
|
|
206
|
+
- \`monitor\` — arm and observe its mail monitor
|
|
207
|
+
- \`workspace-edit\` — edit and test files in its working directory
|
|
208
|
+
- \`status-commands\` — run the inspection commands its briefing prescribes
|
|
209
|
+
|
|
210
|
+
\`doctor\` reports this per role as \`unattended floor: <Role>\`. A role with
|
|
211
|
+
\`unattended: deny\` that cannot meet the floor FAILS doctor, because it will
|
|
212
|
+
deny those requests with nobody to see it; with \`unattended: wait\` it warns,
|
|
213
|
+
because a human can still attach and answer.
|
|
214
|
+
|
|
215
|
+
Security meaning: \`approval: allow\` maps to Claude's \`bypassPermissions\`,
|
|
216
|
+
which genuinely permits the actions the role was authorized to take —
|
|
217
|
+
\`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
|
|
218
|
+
other than an explicit \`allow\` is elevated: \`ask\` stays on Claude's default
|
|
219
|
+
mode and \`deny\` maps to \`plan\`. \`allow\` is therefore a real grant and
|
|
220
|
+
requires explicit authorization; per-role \`isolation:\` remains the outer
|
|
221
|
+
boundary that a permission mode cannot cross.
|
|
222
|
+
|
|
223
|
+
See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
|
|
224
|
+
creation, and \`ours-fleet config\` prints each role's neutral settings, their
|
|
225
|
+
native translation, and any warning — the same text \`doctor\` reports.
|
|
226
|
+
|
|
227
|
+
Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
|
|
228
|
+
dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
|
|
229
|
+
\`mem_palace_midsession_autosave\`.
|
|
230
|
+
|
|
231
|
+
Codex \`harness_options\`: \`launcher\` (auto, ours-codex, codex), \`sandbox\`
|
|
232
|
+
(read-only, workspace-write, danger-full-access), \`approval\` or
|
|
233
|
+
\`permission_mode\` (untrusted, on-request, never), \`profile\`, \`search\`,
|
|
234
|
+
\`config\`, \`add_dirs\`, and \`monitor\`.
|
|
235
|
+
|
|
236
|
+
## ACP adapters
|
|
237
|
+
|
|
238
|
+
The maintained \`@agentclientprotocol/codex-acp\` and
|
|
239
|
+
\`@agentclientprotocol/claude-agent-acp\` runtimes are bundled automatically as
|
|
240
|
+
optional ours-fleet dependencies. The supervisor resolves their executable
|
|
241
|
+
entrypoints internally, so default ACP roles do not depend on global PATH.
|
|
242
|
+
The maintained Claude adapter requires Node 22; tmux and Codex ACP continue to
|
|
243
|
+
work on the ours-fleet core minimum of Node 20.
|
|
244
|
+
|
|
245
|
+
Override an adapter only when necessary with \`session_options.acp.command\`
|
|
246
|
+
(string or argv list). If optional dependencies were deliberately omitted,
|
|
247
|
+
ours-fleet falls back to a compatible globally installed \`codex-acp\` or
|
|
248
|
+
\`claude-agent-acp\`. \`ours-fleet doctor -c FILE\` verifies the resolved adapter.
|
|
249
|
+
|
|
250
|
+
## Reliable mail wake
|
|
251
|
+
|
|
252
|
+
The supervisor monitor is enabled by default. It consumes body-free daemon
|
|
253
|
+
events and advances its durable cursor only after delivery is accepted. ACP uses
|
|
254
|
+
a structured \`session/prompt\`; tmux uses verified console injection. Message
|
|
255
|
+
bodies are released only when the role calls the ours \`get_messages\` tool.
|
|
256
|
+
|
|
257
|
+
Set \`monitor.enabled: false\` only to retain legacy in-session monitoring.
|
|
258
|
+
Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
|
|
259
|
+
\`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
|
|
260
|
+
`;
|
|
261
|
+
/**
|
|
262
|
+
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
263
|
+
*
|
|
264
|
+
* The skills are separate markdown files in two published plugins, written for
|
|
265
|
+
* two different harnesses, so they cannot literally be one file. This is the
|
|
266
|
+
* source of truth they are all written from, and a test holds each variant to
|
|
267
|
+
* it — including the CLI reference above, so a skill and \`ours-fleet docs\`
|
|
268
|
+
* cannot name different permission settings.
|
|
269
|
+
*
|
|
270
|
+
* \`forbidden\` is the more important half. The old skills prescribed
|
|
271
|
+
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
272
|
+
* default while also telling the agent to stop at a failed doctor check — and
|
|
273
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
|
|
274
|
+
* unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
|
|
275
|
+
* fatal. Following the skill produced a role the CLI then refused.
|
|
276
|
+
*/
|
|
277
|
+
export const SPAWN_SKILL_CONTRACT = {
|
|
278
|
+
/** Substrings every variant must contain (whitespace-normalised). */
|
|
279
|
+
required: [
|
|
280
|
+
// The installed reference is authoritative and must actually be read.
|
|
281
|
+
'ours-fleet docs',
|
|
282
|
+
'ours-fleet doctor',
|
|
283
|
+
// Trap 1: a mode that suppresses the prompt without granting the action.
|
|
284
|
+
'dontAsk',
|
|
285
|
+
'bypassPermissions',
|
|
286
|
+
// Trap 2: the floor, and the command that reports it before launch.
|
|
287
|
+
'unattended capability floor',
|
|
288
|
+
'unattended floor:',
|
|
289
|
+
// The only intent that clears the floor, and the honest alternative.
|
|
290
|
+
'--approval allow',
|
|
291
|
+
'--unattended wait',
|
|
292
|
+
// Creation-time isolation (6.3) — the one new operator input this release adds.
|
|
293
|
+
'--isolation-file',
|
|
294
|
+
],
|
|
295
|
+
/**
|
|
296
|
+
* Substrings no variant may contain. Deliberately short: the real guard is
|
|
297
|
+
* the acceptance test, which runs every spawn command a variant prints
|
|
298
|
+
* through the same analysis `doctor` uses and fails if doctor would fail it.
|
|
299
|
+
* This list only pins the specific claim that was wrong.
|
|
300
|
+
*/
|
|
301
|
+
forbidden: [
|
|
302
|
+
// The contradictory blanket default both variants used to prescribe.
|
|
303
|
+
// `ask` grants an unattended role only `read-state`, and `deny` makes the
|
|
304
|
+
// shortfall a doctor FAILURE — so the skill told you to build a role the
|
|
305
|
+
// CLI then refused, in the same breath as telling you to trust doctor.
|
|
306
|
+
'--approval ask --filesystem workspace --unattended deny',
|
|
307
|
+
'[TODO:',
|
|
308
|
+
],
|
|
309
|
+
};
|
package/dist/doctor.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { userInfo } from 'node:os';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { realExec } from './exec.js';
|
|
4
|
-
import { loadConfig } from './config.js';
|
|
5
|
-
import { getAdapter } from './harness/registry.js';
|
|
6
|
-
import {
|
|
4
|
+
import { isolationContextFor, loadConfig } from './config.js';
|
|
5
|
+
import { getAdapter, productionAdapters } from './harness/registry.js';
|
|
6
|
+
import { analyzeFleetPermissions, formatNative } from './permissions.js';
|
|
7
|
+
import { resolveBundledAcpAgent } from './harness/acp-agent.js';
|
|
8
|
+
import { deriveXdgRuntimeDir } from './paths.js';
|
|
7
9
|
import { resolveIsolation } from './isolation/policy.js';
|
|
8
10
|
import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
|
|
9
11
|
import { authResolutionHint, resolveEndpoint, } from './monitor.js';
|
|
@@ -43,11 +45,24 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
43
45
|
name: 'node', ok: major >= 20,
|
|
44
46
|
detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
|
|
45
47
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// The configuration is a checked prerequisite in its own right. A config the
|
|
49
|
+
// `config` command rejects must fail here too, with the same cause — while the
|
|
50
|
+
// host checks below still run, because they are what the operator needs next.
|
|
51
|
+
const loaded = loadConfigResult(opts.configPath);
|
|
52
|
+
const roles = loaded.ok ? loaded.roles : [];
|
|
53
|
+
checks.push(loaded.ok
|
|
54
|
+
? { name: 'config', ok: true, detail: loaded.files.join(' + ') || '(none — no fleet.yaml or fleet.d)' }
|
|
55
|
+
: { name: 'config', ok: false, detail: loaded.error });
|
|
56
|
+
checks.push(loaded.ok
|
|
57
|
+
? { name: 'roles', ok: true, detail: `${roles.length} configured` }
|
|
58
|
+
: { name: 'roles', ok: false, detail: 'unknown — the configuration did not load' });
|
|
59
|
+
if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
|
|
60
|
+
const tmux = await exec('tmux', ['-V']);
|
|
61
|
+
checks.push({
|
|
62
|
+
name: 'tmux', ok: tmux.code === 0,
|
|
63
|
+
detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
51
66
|
const mcp = await exec('ours-mcp', ['--version']);
|
|
52
67
|
checks.push({
|
|
53
68
|
name: 'ours-mcp', ok: mcp.code === 0,
|
|
@@ -80,11 +95,49 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
80
95
|
: `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
|
|
81
96
|
});
|
|
82
97
|
}
|
|
98
|
+
// Per-role permission translation (2.3). Rendered from the same analysis the
|
|
99
|
+
// `config` command prints, so the two commands cannot disagree.
|
|
100
|
+
for (const analysis of analyzeFleetPermissions(roles)) {
|
|
101
|
+
if (!analysis.supported) {
|
|
102
|
+
checks.push({
|
|
103
|
+
name: `permissions: ${analysis.role}`, ok: false,
|
|
104
|
+
detail: analysis.warnings.join('; '),
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const p = analysis.permissions;
|
|
109
|
+
const summary = `approval=${p.approval} filesystem=${p.filesystem} unattended=${p.unattended}`
|
|
110
|
+
+ ` -> ${formatNative(analysis.native)}`;
|
|
111
|
+
checks.push({
|
|
112
|
+
name: `permissions: ${analysis.role}`, ok: true,
|
|
113
|
+
detail: analysis.warnings.length
|
|
114
|
+
? `${summary} — ${analysis.warnings.join('; ')}`
|
|
115
|
+
: `${summary} (exact)`,
|
|
116
|
+
});
|
|
117
|
+
// A role that states its permission intent twice, in two disagreeing places
|
|
118
|
+
// (2.4). Quiet when there is a single source of intent.
|
|
119
|
+
for (const conflict of analysis.conflicts ?? [])
|
|
120
|
+
checks.push({
|
|
121
|
+
name: `permission conflict: ${analysis.role}`, ok: true, detail: conflict.warning,
|
|
122
|
+
});
|
|
123
|
+
// The floor is checked BEFORE start (2.1): an under-permissioned unattended
|
|
124
|
+
// role never reports its own failure, because the denial happens inside the
|
|
125
|
+
// harness with nobody attached to see it.
|
|
126
|
+
const floor = analysis.floor;
|
|
127
|
+
checks.push({
|
|
128
|
+
name: `unattended floor: ${analysis.role}`,
|
|
129
|
+
ok: floor.meets || analysis.floorSeverity !== 'fail',
|
|
130
|
+
detail: floor.meets
|
|
131
|
+
? `grants ${analysis.capabilities.join(', ')}`
|
|
132
|
+
: `MISSING ${floor.missing.join(', ')} — with unattended=${p.unattended} these requests will `
|
|
133
|
+
+ `${p.unattended === 'deny' ? 'be denied silently' : 'block the turn'}; `
|
|
134
|
+
+ `grants only ${analysis.capabilities.join(', ') || '(nothing)'}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
83
137
|
// Isolation reporting (AC-9). Backend availability is advisory — isolation is
|
|
84
138
|
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
85
139
|
// don't use it. Only a role that DECLARES isolation and cannot get it under
|
|
86
140
|
// `strict` is a hard failure.
|
|
87
|
-
const roles = loadConfigSafe(opts.configPath);
|
|
88
141
|
const bw = await makeBubblewrapBackend(exec).available();
|
|
89
142
|
checks.push({
|
|
90
143
|
name: 'isolation: bubblewrap', ok: true,
|
|
@@ -95,13 +148,15 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
95
148
|
if (platform === 'linux')
|
|
96
149
|
checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
|
|
97
150
|
for (const r of roles.filter(r => r.isolation)) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
151
|
+
// A refused mount is a launch-blocking policy error (5.2), not a warning.
|
|
152
|
+
let policy;
|
|
153
|
+
try {
|
|
154
|
+
policy = resolveIsolation(r.isolation, isolationContextFor(r));
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
checks.push({ name: `isolation: ${r.name}`, ok: false, detail: e.message });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
105
160
|
const caps = [
|
|
106
161
|
policy.resources.mem && `mem=${policy.resources.mem}`,
|
|
107
162
|
policy.resources.cpu && `cpu=${policy.resources.cpu}`,
|
|
@@ -157,9 +212,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
157
212
|
}
|
|
158
213
|
checks.push({ name: checkName, ok, detail });
|
|
159
214
|
}
|
|
215
|
+
// A broken config resolves no roles and therefore no harnesses. Without a
|
|
216
|
+
// fallback the AI CLI prerequisites would simply vanish from the report at
|
|
217
|
+
// exactly the moment the operator is trying to work out what is wrong.
|
|
160
218
|
const harnesses = opts.harness
|
|
161
219
|
? [opts.harness]
|
|
162
|
-
:
|
|
220
|
+
: loaded.ok
|
|
221
|
+
? [...new Set(roles.map(r => r.harness))]
|
|
222
|
+
: productionAdapters();
|
|
163
223
|
for (const h of harnesses) {
|
|
164
224
|
try {
|
|
165
225
|
const rep = await getAdapter(h).checkPrereqs();
|
|
@@ -169,13 +229,55 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
169
229
|
checks.push({ name: h, ok: false, detail: e.message });
|
|
170
230
|
}
|
|
171
231
|
}
|
|
232
|
+
for (const role of roles.filter(role => role.session === 'acp')) {
|
|
233
|
+
const configured = role.session_options?.acp?.command;
|
|
234
|
+
const bundled = configured == null
|
|
235
|
+
? role.harness === 'codex'
|
|
236
|
+
? resolveBundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp')
|
|
237
|
+
: role.harness === 'claude-code'
|
|
238
|
+
? resolveBundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp')
|
|
239
|
+
: undefined
|
|
240
|
+
: undefined;
|
|
241
|
+
const command = Array.isArray(configured)
|
|
242
|
+
? configured[0]
|
|
243
|
+
: typeof configured === 'string'
|
|
244
|
+
? configured.trim().split(/\s+/)[0]
|
|
245
|
+
: role.harness === 'codex'
|
|
246
|
+
? 'codex-acp'
|
|
247
|
+
: role.harness === 'claude-code'
|
|
248
|
+
? 'claude-agent-acp'
|
|
249
|
+
: '';
|
|
250
|
+
if (!command) {
|
|
251
|
+
checks.push({
|
|
252
|
+
name: `acp: ${role.name}`, ok: false,
|
|
253
|
+
detail: `harness '${role.harness}' has no default ACP agent; set session_options.acp.command`,
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (bundled?.bundled) {
|
|
258
|
+
checks.push({
|
|
259
|
+
name: `acp: ${role.name}`, ok: true,
|
|
260
|
+
detail: `${command} bundled with ours-fleet`,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const result = await exec('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command]);
|
|
265
|
+
checks.push({
|
|
266
|
+
name: `acp: ${role.name}`,
|
|
267
|
+
ok: result.code === 0,
|
|
268
|
+
detail: result.code === 0
|
|
269
|
+
? `${command} available`
|
|
270
|
+
: `${command} not found or failed — install the ACP adapter or set session_options.acp.command`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
172
273
|
return { ok: checks.every(c => c.ok), checks };
|
|
173
274
|
}
|
|
174
|
-
function
|
|
275
|
+
function loadConfigResult(configPath) {
|
|
175
276
|
try {
|
|
176
|
-
|
|
277
|
+
const cfg = loadConfig(configPath);
|
|
278
|
+
return { ok: true, roles: cfg.roles, files: cfg.files };
|
|
177
279
|
}
|
|
178
|
-
catch {
|
|
179
|
-
return
|
|
280
|
+
catch (e) {
|
|
281
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
180
282
|
}
|
|
181
283
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an ACP agent shipped as an ours-fleet dependency. Running the JS
|
|
3
|
+
* entrypoint through this process's Node avoids depending on npm exposing a
|
|
4
|
+
* transitive dependency's bin on the user's global PATH.
|
|
5
|
+
*/
|
|
6
|
+
export interface AcpAgentResolution {
|
|
7
|
+
argv: string[];
|
|
8
|
+
bundled: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function resolveBundledAcpAgent(packageName: string, binName: string, fallbackCommand: string): AcpAgentResolution;
|
|
11
|
+
export declare function bundledAcpAgent(packageName: string, binName: string, fallbackCommand: string): string[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
export function resolveBundledAcpAgent(packageName, binName, fallbackCommand) {
|
|
6
|
+
try {
|
|
7
|
+
const manifestPath = require.resolve(`${packageName}/package.json`);
|
|
8
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
9
|
+
const relative = typeof manifest.bin === 'string'
|
|
10
|
+
? manifest.bin
|
|
11
|
+
: manifest.bin?.[binName];
|
|
12
|
+
if (!relative)
|
|
13
|
+
return { argv: [fallbackCommand], bundled: false };
|
|
14
|
+
const entrypoint = resolve(dirname(manifestPath), relative);
|
|
15
|
+
if (!existsSync(entrypoint))
|
|
16
|
+
return { argv: [fallbackCommand], bundled: false };
|
|
17
|
+
return { argv: [process.execPath, entrypoint], bundled: true };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Supports development installs that intentionally omit optional
|
|
21
|
+
// dependencies and existing hosts with a globally installed adapter.
|
|
22
|
+
return { argv: [fallbackCommand], bundled: false };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function bundledAcpAgent(packageName, binName, fallbackCommand) {
|
|
26
|
+
return resolveBundledAcpAgent(packageName, binName, fallbackCommand).argv;
|
|
27
|
+
}
|
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
2
|
import type { ResolvedRole } from '../config.js';
|
|
3
|
-
import type { HarnessAdapter } from './types.js';
|
|
3
|
+
import type { HarnessAdapter, UnattendedCapability } from './types.js';
|
|
4
|
+
import { type LockDeps } from '../atomic-file.js';
|
|
5
|
+
/**
|
|
6
|
+
* Neutral approval → native Claude mode. The ONE definition, shared by launch
|
|
7
|
+
* and by translation so the two can never disagree about what a role will run
|
|
8
|
+
* with.
|
|
9
|
+
*
|
|
10
|
+
* `allow` maps to `bypassPermissions`, not `dontAsk`. `dontAsk` suppresses the
|
|
11
|
+
* PROMPT, not the denial: an unattended role configured with the operator's
|
|
12
|
+
* explicit `approval: allow` was silently refused the actions it was told to
|
|
13
|
+
* take, with no prompt and no error to show for it. Only an explicit `allow`
|
|
14
|
+
* gets this; `ask` and `deny` are never elevated.
|
|
15
|
+
*/
|
|
16
|
+
export declare function nativePermissionMode(approval: ResolvedRole['permissions']['approval']): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* What an unattended role can actually do under a native mode. Derived from the
|
|
19
|
+
* NATIVE mode, so an operator's explicit `harness_options.permission_mode`
|
|
20
|
+
* override is judged on what it really grants.
|
|
21
|
+
*/
|
|
22
|
+
export declare function claudeCapabilities(mode: string | undefined, filesystem: ResolvedRole['permissions']['filesystem']): UnattendedCapability[];
|
|
4
23
|
export declare function autocompactPct(role: ResolvedRole): number;
|
|
5
|
-
/**
|
|
6
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Pre-trust a dir in ~/.claude.json so the first launch never blocks on the
|
|
26
|
+
* trust dialog.
|
|
27
|
+
*
|
|
28
|
+
* `~/.claude.json` is SHARED by every role and by the operator's own Claude
|
|
29
|
+
* Code. The previous read-modify-write held nothing while it worked, so two
|
|
30
|
+
* roles starting together interleaved and one silently lost its trust entry —
|
|
31
|
+
* and that role then blocked on the dialog it was supposed to be spared,
|
|
32
|
+
* unattended, with nobody to answer it. A crash mid-write truncated the file
|
|
33
|
+
* for everyone.
|
|
34
|
+
*
|
|
35
|
+
* Now: take a cross-process lock, re-read inside it, merge ONLY this project's
|
|
36
|
+
* entry so unrelated operator state survives untouched, and replace the file
|
|
37
|
+
* atomically. Never fatal — a role that cannot be pre-trusted still launches.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pretrust(dir: string, deps?: {
|
|
40
|
+
log?(line: string): void;
|
|
41
|
+
lock?: LockDeps;
|
|
42
|
+
}): Promise<void>;
|
|
7
43
|
export declare function makeClaudeCodeAdapter(exec?: Exec): HarnessAdapter;
|
|
8
44
|
export declare const claudeCodeAdapter: HarnessAdapter;
|