@ours.network/fleet 0.10.0-nightly.4 → 0.10.1
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 +138 -21
- 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 +43 -13
- package/dist/cli.js +98 -22
- package/dist/config.d.ts +24 -3
- package/dist/config.js +84 -11
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +155 -8
- package/dist/doctor.js +75 -17
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +128 -26
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +58 -11
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +51 -4
- 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 -7
- package/dist/monitor.js +157 -35
- 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 +72 -2
- package/dist/runner.js +291 -28
- package/dist/session/acp.d.ts +25 -2
- package/dist/session/acp.js +143 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +9 -2
- package/dist/session/tmux.js +36 -4
- package/dist/session/types.d.ts +99 -2
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- 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 +1 -1
package/dist/creation.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
|
+
import { stateRoot } from './paths.js';
|
|
5
|
+
import { resolveEndpoint } from './monitor.js';
|
|
6
|
+
/**
|
|
7
|
+
* One creation transaction: role name and ours identity reserved together,
|
|
8
|
+
* every artifact journalled, and everything undone in reverse on failure.
|
|
9
|
+
*
|
|
10
|
+
* The problem this replaces is a check followed by a create. `assertNameFree()`
|
|
11
|
+
* read the config and the agent dirs, returned, and only then did the caller
|
|
12
|
+
* start writing — so two concurrent spawns of the same name both passed the
|
|
13
|
+
* check, both wrote, and the second silently overwrote the first's fleet.d file
|
|
14
|
+
* while inheriting its half-built state. Nothing was atomic and nothing was
|
|
15
|
+
* undone.
|
|
16
|
+
*/
|
|
17
|
+
/** Where host-wide creation state lives. One directory, so it is easy to inspect. */
|
|
18
|
+
const creationRoot = () => join(stateRoot(), 'creation');
|
|
19
|
+
const creationLock = () => join(creationRoot(), '.lock');
|
|
20
|
+
const reservationsDir = () => join(creationRoot(), 'reservations');
|
|
21
|
+
/** A reservation is one file; its existence IS the claim. */
|
|
22
|
+
const reservationPath = (kind, name) => join(reservationsDir(), `${kind}-${encodeURIComponent(name)}`);
|
|
23
|
+
export class CreationConflictError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'CreationConflictError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Host-local identity reservation: atomic across every ours-fleet process on
|
|
31
|
+
* this host, because it is taken under the same host-wide creation lock as the
|
|
32
|
+
* role name.
|
|
33
|
+
*
|
|
34
|
+
* It is NOT atomic against other clients of the same ours daemon — another tool
|
|
35
|
+
* creating the identity between our reservation and our creation would still
|
|
36
|
+
* win. Closing that needs a reserve/commit/release operation in the daemon
|
|
37
|
+
* itself; see the release notes.
|
|
38
|
+
*/
|
|
39
|
+
export const hostLocalIdentityRegistry = {
|
|
40
|
+
async reserve(name) {
|
|
41
|
+
const p = reservationPath('identity', name);
|
|
42
|
+
if (existsSync(p))
|
|
43
|
+
return false;
|
|
44
|
+
mkdirSync(reservationsDir(), { recursive: true });
|
|
45
|
+
writeFileSync(p, `${process.pid} ${new Date().toISOString()}\n`);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
async release(name) {
|
|
49
|
+
rmSync(reservationPath('identity', name), { force: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/** Is this role name already reserved by an in-flight transaction? */
|
|
53
|
+
const roleReserved = (name) => existsSync(reservationPath('role', name));
|
|
54
|
+
/**
|
|
55
|
+
* Run `body` inside a creation transaction.
|
|
56
|
+
*
|
|
57
|
+
* Under one host-wide lock: both names are reserved, then `body` builds the
|
|
58
|
+
* role. If anything throws, every recorded stage is undone in reverse order and
|
|
59
|
+
* both reservations are released, so the names can be reused immediately. On
|
|
60
|
+
* success the reservations are released too — the role's own config and state
|
|
61
|
+
* are the durable record from then on.
|
|
62
|
+
*
|
|
63
|
+
* Rollback errors are collected and reported, never allowed to hide the failure
|
|
64
|
+
* that caused the rollback.
|
|
65
|
+
*/
|
|
66
|
+
export async function withCreationTransaction(names, body, deps = {}) {
|
|
67
|
+
const log = deps.log ?? (() => { });
|
|
68
|
+
const registry = deps.identityRegistry ?? hostLocalIdentityRegistry;
|
|
69
|
+
mkdirSync(creationRoot(), { recursive: true });
|
|
70
|
+
const journal = [];
|
|
71
|
+
const tx = {
|
|
72
|
+
record: entry => { journal.push(entry); },
|
|
73
|
+
get stages() { return journal.map(e => e.stage); },
|
|
74
|
+
};
|
|
75
|
+
// Both names, one boundary. Reserving the role and then the identity without
|
|
76
|
+
// a shared lock would let two spawns each win one.
|
|
77
|
+
const held = await withFileLock(creationLock(), async () => {
|
|
78
|
+
if (roleReserved(names.role))
|
|
79
|
+
throw new CreationConflictError(`role '${names.role}' is being created by another process right now`);
|
|
80
|
+
if (!await registry.reserve(names.identity))
|
|
81
|
+
throw new CreationConflictError(`ours identity '${names.identity}' is already taken or being created right now`);
|
|
82
|
+
mkdirSync(reservationsDir(), { recursive: true });
|
|
83
|
+
writeFileSync(reservationPath('role', names.role), `${process.pid} ${new Date().toISOString()}\n`);
|
|
84
|
+
return true;
|
|
85
|
+
}, deps.lock);
|
|
86
|
+
if (!held)
|
|
87
|
+
throw new CreationConflictError('could not take the creation lock');
|
|
88
|
+
const releaseAll = async () => {
|
|
89
|
+
rmSync(reservationPath('role', names.role), { force: true });
|
|
90
|
+
await registry.release(names.identity).catch(() => undefined);
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
const result = await body(tx);
|
|
94
|
+
await releaseAll();
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const rollbackFailures = [];
|
|
99
|
+
for (const entry of [...journal].reverse()) {
|
|
100
|
+
try {
|
|
101
|
+
await entry.undo();
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
rollbackFailures.push(`${entry.stage}: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await releaseAll();
|
|
108
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
109
|
+
if (rollbackFailures.length) {
|
|
110
|
+
log(`creation rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
111
|
+
original.message += ` (rollback also failed: ${rollbackFailures.join('; ')})`;
|
|
112
|
+
}
|
|
113
|
+
throw original;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Forget reservations left behind by a process that died mid-transaction. */
|
|
117
|
+
export function clearStaleReservations(olderThanMs = 60_000, now = Date.now()) {
|
|
118
|
+
const dir = reservationsDir();
|
|
119
|
+
if (!existsSync(dir))
|
|
120
|
+
return 0;
|
|
121
|
+
let cleared = 0;
|
|
122
|
+
for (const f of readdirSyncSafe(dir)) {
|
|
123
|
+
const p = join(dir, f);
|
|
124
|
+
try {
|
|
125
|
+
const stamp = Date.parse(readFileSync(p, 'utf8').trim().split(/\s+/)[1] ?? '');
|
|
126
|
+
if (Number.isFinite(stamp) && now - stamp > olderThanMs) {
|
|
127
|
+
rmSync(p, { force: true });
|
|
128
|
+
cleared++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* unreadable: leave it for a human */ }
|
|
132
|
+
}
|
|
133
|
+
return cleared;
|
|
134
|
+
}
|
|
135
|
+
function readdirSyncSafe(dir) {
|
|
136
|
+
try {
|
|
137
|
+
return readdirSync(dir);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Establish the identity before the role's service is enabled.
|
|
145
|
+
*
|
|
146
|
+
* Returns what was actually GUARANTEED, so the generated briefing can say
|
|
147
|
+
* something true instead of asserting a "predefined" identity nobody checked —
|
|
148
|
+
* the failure a real agent hit on its first boot, having been told to bind an
|
|
149
|
+
* identity that did not exist.
|
|
150
|
+
*/
|
|
151
|
+
export async function ensureIdentity(name, profile, provisioner, log = () => { }) {
|
|
152
|
+
if (!provisioner)
|
|
153
|
+
return { state: 'unverified', detail: 'no identity provisioner is configured' };
|
|
154
|
+
let present;
|
|
155
|
+
try {
|
|
156
|
+
present = await provisioner.exists(name);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
present = 'unknown';
|
|
160
|
+
log(`identity '${name}': could not be verified (${e.message})`);
|
|
161
|
+
}
|
|
162
|
+
if (present === true)
|
|
163
|
+
return { state: 'verified', detail: 'the ours daemon reports it exists' };
|
|
164
|
+
if (present === 'unknown')
|
|
165
|
+
return { state: 'unverified', detail: 'the ours daemon could not be asked' };
|
|
166
|
+
if (!provisioner.create) {
|
|
167
|
+
// Loud, and named. The briefing will tell the agent to mint it — which is
|
|
168
|
+
// what actually happens today — but nobody is told it was "predefined".
|
|
169
|
+
log(`identity '${name}' does not exist and this host cannot create one automatically — `
|
|
170
|
+
+ `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
|
|
171
|
+
return { state: 'unverified', detail: 'it does not exist and cannot be created here' };
|
|
172
|
+
}
|
|
173
|
+
await provisioner.create(name, profile);
|
|
174
|
+
return { state: 'created', detail: 'created during spawn, with its bio and persona published' };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Ask the running ours daemon whether an identity exists, over the same
|
|
178
|
+
* authenticated endpoint doctor already probes. Answers `unknown` rather than
|
|
179
|
+
* guessing when the daemon cannot be reached — an unreachable daemon is not
|
|
180
|
+
* evidence that the identity is missing.
|
|
181
|
+
*
|
|
182
|
+
* It deliberately has no `create()`: role identities are minted through the MCP
|
|
183
|
+
* `create_identity` tool, and inventing a daemon endpoint we cannot test is the
|
|
184
|
+
* failure mode this release exists to stop.
|
|
185
|
+
*/
|
|
186
|
+
export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
|
|
187
|
+
return {
|
|
188
|
+
async exists(name) {
|
|
189
|
+
const ep = resolveEndpoint(env);
|
|
190
|
+
const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
|
|
191
|
+
if (!resp.ok)
|
|
192
|
+
return 'unknown';
|
|
193
|
+
const body = await resp.json();
|
|
194
|
+
if (!Array.isArray(body.identities))
|
|
195
|
+
return 'unknown';
|
|
196
|
+
return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Atomically write a role's fleet.d file, journalling it for rollback. */
|
|
201
|
+
export function writeRoleFile(tx, file, contents) {
|
|
202
|
+
const existed = existsSync(file);
|
|
203
|
+
replaceFileAtomically(file, contents, 0o644);
|
|
204
|
+
tx.record({
|
|
205
|
+
stage: `fleet.d file ${file}`,
|
|
206
|
+
// Only remove what THIS transaction created; never delete a file the
|
|
207
|
+
// operator already had.
|
|
208
|
+
undo: () => { if (!existed)
|
|
209
|
+
rmSync(file, { force: true }); },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export const CREATION_PROVENANCE_FILE = 'creation.json';
|
|
213
|
+
/**
|
|
214
|
+
* Record HOW a role was created, so nobody has to remember.
|
|
215
|
+
*
|
|
216
|
+
* Six months on, "why does this role have `approval: allow`?" is unanswerable:
|
|
217
|
+
* the resolved config shows the value but not whether an operator typed it, a
|
|
218
|
+
* fleet default supplied it, or it fell through to a built-in. Those have very
|
|
219
|
+
* different implications for whether it is safe to change.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
|
|
222
|
+
* first two can carry credentials, and this file exists to be read — it must
|
|
223
|
+
* never become a place secrets accumulate.
|
|
224
|
+
*/
|
|
225
|
+
export function buildProvenance(o) {
|
|
226
|
+
return {
|
|
227
|
+
version: 1,
|
|
228
|
+
command: 'ours-fleet spawn',
|
|
229
|
+
fleetVersion: o.fleetVersion,
|
|
230
|
+
createdAt: (o.now ?? new Date()).toISOString(),
|
|
231
|
+
lifetime: o.lifetime,
|
|
232
|
+
role: o.role,
|
|
233
|
+
settings: o.settings,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Write the provenance record atomically, before the role is started. */
|
|
237
|
+
export function writeProvenance(stateDir, p) {
|
|
238
|
+
replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
|
|
239
|
+
}
|
|
240
|
+
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
241
|
+
export function formatProvenance(p) {
|
|
242
|
+
const mark = { cli: 'explicit', 'fleet-default': 'fleet default', 'built-in': 'built-in' };
|
|
243
|
+
return Object.entries(p.settings)
|
|
244
|
+
.filter(([, e]) => e.value !== undefined)
|
|
245
|
+
.map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
|
|
246
|
+
}
|
|
247
|
+
/** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
|
|
248
|
+
export function provenanceOf(cliValue, fleetDefault, builtIn) {
|
|
249
|
+
if (cliValue !== undefined && cliValue !== null && cliValue !== '')
|
|
250
|
+
return { value: cliValue, source: 'cli' };
|
|
251
|
+
if (fleetDefault !== undefined && fleetDefault !== null)
|
|
252
|
+
return { value: fleetDefault, source: 'fleet-default' };
|
|
253
|
+
return { value: builtIn, source: 'built-in' };
|
|
254
|
+
}
|
package/dist/docs.d.ts
CHANGED
|
@@ -4,4 +4,31 @@
|
|
|
4
4
|
* Keep this concise enough to place directly in an agent context. Unlike
|
|
5
5
|
* Commander's per-command help, this describes how the pieces compose.
|
|
6
6
|
*/
|
|
7
|
-
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and `--monitor`. Run `ours-fleet help spawn` for exact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n enabled: true\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n enabled: true\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\nThe supervisor monitor is enabled by default. It consumes body-free daemon\nevents and advances its durable cursor only after delivery is accepted. ACP uses\na structured `session/prompt`; tmux uses verified console injection. Message\nbodies are released only when the role calls the ours `get_messages` tool.\n\nSet `monitor.enabled: false` only to retain legacy in-session monitoring.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
|
|
7
|
+
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
|
|
8
|
+
/**
|
|
9
|
+
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
10
|
+
*
|
|
11
|
+
* The skills are separate markdown files in two published plugins, written for
|
|
12
|
+
* two different harnesses, so they cannot literally be one file. This is the
|
|
13
|
+
* source of truth they are all written from, and a test holds each variant to
|
|
14
|
+
* it — including the CLI reference above, so a skill and \`ours-fleet docs\`
|
|
15
|
+
* cannot name different permission settings.
|
|
16
|
+
*
|
|
17
|
+
* \`forbidden\` is the more important half. The old skills prescribed
|
|
18
|
+
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
19
|
+
* default while also telling the agent to stop at a failed doctor check — and
|
|
20
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
|
|
21
|
+
* unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
|
|
22
|
+
* fatal. Following the skill produced a role the CLI then refused.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SPAWN_SKILL_CONTRACT: {
|
|
25
|
+
/** Substrings every variant must contain (whitespace-normalised). */
|
|
26
|
+
readonly required: readonly ["ours-fleet docs", "ours-fleet doctor", "dontAsk", "bypassPermissions", "unattended capability floor", "unattended floor:", "--approval allow", "--unattended wait", "--isolation-file"];
|
|
27
|
+
/**
|
|
28
|
+
* Substrings no variant may contain. Deliberately short: the real guard is
|
|
29
|
+
* the acceptance test, which runs every spawn command a variant prints
|
|
30
|
+
* through the same analysis `doctor` uses and fails if doctor would fail it.
|
|
31
|
+
* This list only pins the specific claim that was wrong.
|
|
32
|
+
*/
|
|
33
|
+
readonly forbidden: readonly ["--approval ask --filesystem workspace --unattended deny", "[TODO:"];
|
|
34
|
+
};
|
package/dist/docs.js
CHANGED
|
@@ -64,7 +64,9 @@ role after exit/reboot. Both lifetimes support \`--session acp\`.
|
|
|
64
64
|
|
|
65
65
|
Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
|
|
66
66
|
\`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
|
|
67
|
-
\`--add-dir\`, and \`--monitor
|
|
67
|
+
\`--add-dir\`, and legacy \`--monitor\` (consent for the native Codex monitor,
|
|
68
|
+
not the \`monitor.mode\` wake-owner selector). Run \`ours-fleet help spawn\` for
|
|
69
|
+
exact values.
|
|
68
70
|
|
|
69
71
|
## fleet.yaml
|
|
70
72
|
|
|
@@ -81,7 +83,7 @@ defaults:
|
|
|
81
83
|
filesystem: workspace
|
|
82
84
|
unattended: deny
|
|
83
85
|
monitor:
|
|
84
|
-
|
|
86
|
+
mode: fleet # fleet (default) | native
|
|
85
87
|
roles:
|
|
86
88
|
Coordinator:
|
|
87
89
|
harness: codex
|
|
@@ -100,7 +102,8 @@ roles:
|
|
|
100
102
|
tmux:
|
|
101
103
|
boot_grace_ms: 10000
|
|
102
104
|
monitor:
|
|
103
|
-
|
|
105
|
+
mode: fleet # fleet supervisor | native harness monitor
|
|
106
|
+
interrupt: false # true cancels active work before every configured wake
|
|
104
107
|
wake_sources: [message_received, file_received, local_contact_request, pending_message]
|
|
105
108
|
batch_ms: 2000
|
|
106
109
|
inject: notification
|
|
@@ -141,6 +144,89 @@ The backend translates this common intent. Harness-native settings in
|
|
|
141
144
|
\`allow\`/\`unrestricted\`, Codex \`never\`/\`danger-full-access\`, or Claude
|
|
142
145
|
\`bypassPermissions\` without explicit authorization.
|
|
143
146
|
|
|
147
|
+
### Creation-time isolation
|
|
148
|
+
|
|
149
|
+
\`ours-fleet spawn --isolation-file <path>\` supplies a role's sandbox policy at
|
|
150
|
+
creation, so the FIRST launch is already confined — a role that only gains
|
|
151
|
+
\`isolation:\` on a later \`up\` ran unsandboxed until then.
|
|
152
|
+
|
|
153
|
+
The file holds exactly the \`isolation:\` mapping documented above and nothing
|
|
154
|
+
else — the same schema, validated by the same code, so a policy written here
|
|
155
|
+
cannot mean something different from the identical block in fleet.yaml:
|
|
156
|
+
|
|
157
|
+
\`\`\`yaml
|
|
158
|
+
network: deny
|
|
159
|
+
fs:
|
|
160
|
+
read: [/opt/reference]
|
|
161
|
+
resources:
|
|
162
|
+
mem: 2G
|
|
163
|
+
\`\`\`
|
|
164
|
+
|
|
165
|
+
Invalid files are rejected before anything is created: no config, no state
|
|
166
|
+
directory, no identity reservation. Works for both permanent and \`--temp\` roles.
|
|
167
|
+
|
|
168
|
+
### Never-prompt failure
|
|
169
|
+
|
|
170
|
+
The failure this section exists to prevent leaves no error message anywhere.
|
|
171
|
+
|
|
172
|
+
An unattended role has no console. When the harness needs a permission decision
|
|
173
|
+
there is nobody to ask, so the request is refused INSIDE the harness — no
|
|
174
|
+
prompt, no error, no log line. The agent simply does less than its briefing told
|
|
175
|
+
it to, reports success, and nothing distinguishes that from having done the
|
|
176
|
+
work. Two settings produce it:
|
|
177
|
+
|
|
178
|
+
1. a permission mode that suppresses the prompt without granting the action
|
|
179
|
+
(Claude \`dontAsk\`, which is why neutral \`allow\` maps to
|
|
180
|
+
\`bypassPermissions\` instead); and
|
|
181
|
+
2. \`unattended: deny\`, which refuses every request that reaches it.
|
|
182
|
+
|
|
183
|
+
**Automatic decisions are now recorded.** Every permission request decided
|
|
184
|
+
without a human emits a completed event into
|
|
185
|
+
\`~/.ours-fleet/agents/<Name>/.session-events.jsonl\` carrying the decision,
|
|
186
|
+
whether policy or a person made it, the policy that produced it
|
|
187
|
+
(\`permissions.unattended=deny\` vs \`permissions.approval=deny\`/\`=allow\`),
|
|
188
|
+
the reason, and the option selected. \`ours-fleet peek\` and \`attach\` render
|
|
189
|
+
them. Automatic denial asks for a one-shot rejection, never a standing one, so a
|
|
190
|
+
single unattended refusal cannot disable a tool for the rest of the session.
|
|
191
|
+
|
|
192
|
+
A role that can auto-deny logs one line at startup saying so.
|
|
193
|
+
|
|
194
|
+
To detect an under-permissioned role BEFORE it runs, use the capability floor
|
|
195
|
+
below: \`ours-fleet doctor\` fails such a role rather than letting it discover
|
|
196
|
+
the problem silently at work.
|
|
197
|
+
|
|
198
|
+
### The unattended capability floor
|
|
199
|
+
|
|
200
|
+
An unattended role has no console, so a permission request cannot be answered —
|
|
201
|
+
it is refused, silently, inside the harness. The agent then does less than it
|
|
202
|
+
was told to and reports no error. To make that visible before launch,
|
|
203
|
+
\`ours-fleet config\` and \`ours-fleet doctor\` resolve each role's neutral
|
|
204
|
+
permissions through its harness and check the result against a fixed floor:
|
|
205
|
+
|
|
206
|
+
- \`read-state\` — read its briefing, ROUTINES.md, and WORKLOG.md
|
|
207
|
+
- \`write-state\` — append its WORKLOG and its own state files
|
|
208
|
+
- \`messaging\` — bind its identity, send and receive ours mail
|
|
209
|
+
- \`monitor\` — arm and observe its mail monitor
|
|
210
|
+
- \`workspace-edit\` — edit and test files in its working directory
|
|
211
|
+
- \`status-commands\` — run the inspection commands its briefing prescribes
|
|
212
|
+
|
|
213
|
+
\`doctor\` reports this per role as \`unattended floor: <Role>\`. A role with
|
|
214
|
+
\`unattended: deny\` that cannot meet the floor FAILS doctor, because it will
|
|
215
|
+
deny those requests with nobody to see it; with \`unattended: wait\` it warns,
|
|
216
|
+
because a human can still attach and answer.
|
|
217
|
+
|
|
218
|
+
Security meaning: \`approval: allow\` maps to Claude's \`bypassPermissions\`,
|
|
219
|
+
which genuinely permits the actions the role was authorized to take —
|
|
220
|
+
\`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
|
|
221
|
+
other than an explicit \`allow\` is elevated: \`ask\` stays on Claude's default
|
|
222
|
+
mode and \`deny\` maps to \`plan\`. \`allow\` is therefore a real grant and
|
|
223
|
+
requires explicit authorization; per-role \`isolation:\` remains the outer
|
|
224
|
+
boundary that a permission mode cannot cross.
|
|
225
|
+
|
|
226
|
+
See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
|
|
227
|
+
creation, and \`ours-fleet config\` prints each role's neutral settings, their
|
|
228
|
+
native translation, and any warning — the same text \`doctor\` reports.
|
|
229
|
+
|
|
144
230
|
Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
|
|
145
231
|
dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
|
|
146
232
|
\`mem_palace_midsession_autosave\`.
|
|
@@ -166,12 +252,73 @@ ours-fleet falls back to a compatible globally installed \`codex-acp\` or
|
|
|
166
252
|
|
|
167
253
|
## Reliable mail wake
|
|
168
254
|
|
|
169
|
-
|
|
170
|
-
events and advances its durable cursor only after delivery is accepted. ACP uses
|
|
171
|
-
a structured \`session/prompt\`; tmux uses verified console injection. Message
|
|
172
|
-
bodies are released only when the role calls the ours \`get_messages\` tool.
|
|
255
|
+
\`monitor.mode\` selects exactly one wake owner:
|
|
173
256
|
|
|
174
|
-
|
|
257
|
+
- \`fleet\` (default): the ours-fleet supervisor consumes body-free daemon
|
|
258
|
+
events and advances its durable cursor only after delivery is accepted. ACP
|
|
259
|
+
uses live steering when supported and falls back to structured
|
|
260
|
+
\`session/prompt\`; tmux uses verified console injection.
|
|
261
|
+
- \`native\`: ours-fleet starts no supervisor monitor; the generated briefing
|
|
262
|
+
instructs Claude Code or Codex to arm its harness-native wake mechanism.
|
|
263
|
+
|
|
264
|
+
Set \`monitor.interrupt: true\` in fleet mode to cancel active work before every
|
|
265
|
+
configured wake. The policy is content-blind because the supervisor cannot
|
|
266
|
+
inspect encrypted message bodies. Message bodies are released only when the
|
|
267
|
+
role calls the ours \`get_messages\` tool.
|
|
268
|
+
|
|
269
|
+
Legacy \`monitor.enabled: true|false\` remains accepted as an alias for
|
|
270
|
+
\`mode: fleet|native\`; use \`mode\` in new configuration. Codex's separate
|
|
271
|
+
\`harness_options.monitor: true\` is native-monitor consent, not monitor-owner
|
|
272
|
+
selection.
|
|
175
273
|
Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
|
|
176
274
|
\`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
|
|
177
275
|
`;
|
|
276
|
+
/**
|
|
277
|
+
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
278
|
+
*
|
|
279
|
+
* The skills are separate markdown files in two published plugins, written for
|
|
280
|
+
* two different harnesses, so they cannot literally be one file. This is the
|
|
281
|
+
* source of truth they are all written from, and a test holds each variant to
|
|
282
|
+
* it — including the CLI reference above, so a skill and \`ours-fleet docs\`
|
|
283
|
+
* cannot name different permission settings.
|
|
284
|
+
*
|
|
285
|
+
* \`forbidden\` is the more important half. The old skills prescribed
|
|
286
|
+
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
287
|
+
* default while also telling the agent to stop at a failed doctor check — and
|
|
288
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
|
|
289
|
+
* unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
|
|
290
|
+
* fatal. Following the skill produced a role the CLI then refused.
|
|
291
|
+
*/
|
|
292
|
+
export const SPAWN_SKILL_CONTRACT = {
|
|
293
|
+
/** Substrings every variant must contain (whitespace-normalised). */
|
|
294
|
+
required: [
|
|
295
|
+
// The installed reference is authoritative and must actually be read.
|
|
296
|
+
'ours-fleet docs',
|
|
297
|
+
'ours-fleet doctor',
|
|
298
|
+
// Trap 1: a mode that suppresses the prompt without granting the action.
|
|
299
|
+
'dontAsk',
|
|
300
|
+
'bypassPermissions',
|
|
301
|
+
// Trap 2: the floor, and the command that reports it before launch.
|
|
302
|
+
'unattended capability floor',
|
|
303
|
+
'unattended floor:',
|
|
304
|
+
// The only intent that clears the floor, and the honest alternative.
|
|
305
|
+
'--approval allow',
|
|
306
|
+
'--unattended wait',
|
|
307
|
+
// Creation-time isolation (6.3) — the one new operator input this release adds.
|
|
308
|
+
'--isolation-file',
|
|
309
|
+
],
|
|
310
|
+
/**
|
|
311
|
+
* Substrings no variant may contain. Deliberately short: the real guard is
|
|
312
|
+
* the acceptance test, which runs every spawn command a variant prints
|
|
313
|
+
* through the same analysis `doctor` uses and fails if doctor would fail it.
|
|
314
|
+
* This list only pins the specific claim that was wrong.
|
|
315
|
+
*/
|
|
316
|
+
forbidden: [
|
|
317
|
+
// The contradictory blanket default both variants used to prescribe.
|
|
318
|
+
// `ask` grants an unattended role only `read-state`, and `deny` makes the
|
|
319
|
+
// shortfall a doctor FAILURE — so the skill told you to build a role the
|
|
320
|
+
// CLI then refused, in the same breath as telling you to trust doctor.
|
|
321
|
+
'--approval ask --filesystem workspace --unattended deny',
|
|
322
|
+
'[TODO:',
|
|
323
|
+
],
|
|
324
|
+
};
|
package/dist/doctor.js
CHANGED
|
@@ -1,10 +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';
|
|
4
|
+
import { isolationContextFor, loadConfig } from './config.js';
|
|
5
|
+
import { getAdapter, productionAdapters } from './harness/registry.js';
|
|
6
|
+
import { analyzeFleetPermissions, formatNative } from './permissions.js';
|
|
6
7
|
import { resolveBundledAcpAgent } from './harness/acp-agent.js';
|
|
7
|
-
import {
|
|
8
|
+
import { deriveXdgRuntimeDir } from './paths.js';
|
|
8
9
|
import { resolveIsolation } from './isolation/policy.js';
|
|
9
10
|
import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
|
|
10
11
|
import { authResolutionHint, resolveEndpoint, } from './monitor.js';
|
|
@@ -24,7 +25,7 @@ function cgroupDelegationDetail() {
|
|
|
24
25
|
/** Resolve and deduplicate the effective daemon profiles used by monitored roles. */
|
|
25
26
|
function resolveMonitorProfiles(roles) {
|
|
26
27
|
const profiles = [];
|
|
27
|
-
for (const role of roles.filter(r => r.monitor?.
|
|
28
|
+
for (const role of roles.filter(r => r.monitor?.mode === 'fleet')) {
|
|
28
29
|
const endpoint = resolveEndpoint({ ...process.env, ...(role.env ?? {}) });
|
|
29
30
|
const token = endpoint.headers['x-ours-api-token'];
|
|
30
31
|
const existing = profiles.find(p => p.endpoint.origin === endpoint.origin
|
|
@@ -44,7 +45,17 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
44
45
|
name: 'node', ok: major >= 20,
|
|
45
46
|
detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
|
|
46
47
|
});
|
|
47
|
-
|
|
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' });
|
|
48
59
|
if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
|
|
49
60
|
const tmux = await exec('tmux', ['-V']);
|
|
50
61
|
checks.push({
|
|
@@ -84,6 +95,45 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
84
95
|
: `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
|
|
85
96
|
});
|
|
86
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
|
+
}
|
|
87
137
|
// Isolation reporting (AC-9). Backend availability is advisory — isolation is
|
|
88
138
|
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
89
139
|
// don't use it. Only a role that DECLARES isolation and cannot get it under
|
|
@@ -98,13 +148,15 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
98
148
|
if (platform === 'linux')
|
|
99
149
|
checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
|
|
100
150
|
for (const r of roles.filter(r => r.isolation)) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
|
108
160
|
const caps = [
|
|
109
161
|
policy.resources.mem && `mem=${policy.resources.mem}`,
|
|
110
162
|
policy.resources.cpu && `cpu=${policy.resources.cpu}`,
|
|
@@ -160,9 +212,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
160
212
|
}
|
|
161
213
|
checks.push({ name: checkName, ok, detail });
|
|
162
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.
|
|
163
218
|
const harnesses = opts.harness
|
|
164
219
|
? [opts.harness]
|
|
165
|
-
:
|
|
220
|
+
: loaded.ok
|
|
221
|
+
? [...new Set(roles.map(r => r.harness))]
|
|
222
|
+
: productionAdapters();
|
|
166
223
|
for (const h of harnesses) {
|
|
167
224
|
try {
|
|
168
225
|
const rep = await getAdapter(h).checkPrereqs();
|
|
@@ -215,11 +272,12 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
215
272
|
}
|
|
216
273
|
return { ok: checks.every(c => c.ok), checks };
|
|
217
274
|
}
|
|
218
|
-
function
|
|
275
|
+
function loadConfigResult(configPath) {
|
|
219
276
|
try {
|
|
220
|
-
|
|
277
|
+
const cfg = loadConfig(configPath);
|
|
278
|
+
return { ok: true, roles: cfg.roles, files: cfg.files };
|
|
221
279
|
}
|
|
222
|
-
catch {
|
|
223
|
-
return
|
|
280
|
+
catch (e) {
|
|
281
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
224
282
|
}
|
|
225
283
|
}
|