@ours.network/fleet 0.9.5 → 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 +101 -0
- 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 +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- 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 +30 -3
- package/dist/monitor.js +63 -25
- 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 +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- 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/docs.js
CHANGED
|
@@ -141,6 +141,89 @@ The backend translates this common intent. Harness-native settings in
|
|
|
141
141
|
\`allow\`/\`unrestricted\`, Codex \`never\`/\`danger-full-access\`, or Claude
|
|
142
142
|
\`bypassPermissions\` without explicit authorization.
|
|
143
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
|
+
|
|
144
227
|
Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
|
|
145
228
|
dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
|
|
146
229
|
\`mem_palace_midsession_autosave\`.
|
|
@@ -175,3 +258,52 @@ Set \`monitor.enabled: false\` only to retain legacy in-session monitoring.
|
|
|
175
258
|
Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
|
|
176
259
|
\`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
|
|
177
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,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';
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -1,22 +1,55 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { home } from '../paths.js';
|
|
4
4
|
import { realExec } from '../exec.js';
|
|
5
5
|
import { registerAdapter } from './registry.js';
|
|
6
|
+
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
7
|
+
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
6
8
|
import { bundledAcpAgent } from './acp-agent.js';
|
|
7
9
|
const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode'];
|
|
8
10
|
/** Claude Code's accepted --permission-mode values. */
|
|
9
11
|
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
|
|
12
|
+
/**
|
|
13
|
+
* Neutral approval → native Claude mode. The ONE definition, shared by launch
|
|
14
|
+
* and by translation so the two can never disagree about what a role will run
|
|
15
|
+
* with.
|
|
16
|
+
*
|
|
17
|
+
* `allow` maps to `bypassPermissions`, not `dontAsk`. `dontAsk` suppresses the
|
|
18
|
+
* PROMPT, not the denial: an unattended role configured with the operator's
|
|
19
|
+
* explicit `approval: allow` was silently refused the actions it was told to
|
|
20
|
+
* take, with no prompt and no error to show for it. Only an explicit `allow`
|
|
21
|
+
* gets this; `ask` and `deny` are never elevated.
|
|
22
|
+
*/
|
|
23
|
+
export function nativePermissionMode(approval) {
|
|
24
|
+
switch (approval) {
|
|
25
|
+
case 'allow': return 'bypassPermissions';
|
|
26
|
+
case 'deny': return 'plan';
|
|
27
|
+
default: return undefined; // 'ask' → Claude's own default
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What an unattended role can actually do under a native mode. Derived from the
|
|
32
|
+
* NATIVE mode, so an operator's explicit `harness_options.permission_mode`
|
|
33
|
+
* override is judged on what it really grants.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeCapabilities(mode, filesystem) {
|
|
36
|
+
// `plan` may not act at all; `default` and `acceptEdits` still stop to ask,
|
|
37
|
+
// and with no console attached that request is refused rather than answered.
|
|
38
|
+
if (mode !== 'bypassPermissions' && mode !== 'dontAsk')
|
|
39
|
+
return ['read-state'];
|
|
40
|
+
// `dontAsk` reaches the tools but is refused the actions behind them.
|
|
41
|
+
if (mode === 'dontAsk')
|
|
42
|
+
return ['read-state', 'status-commands'];
|
|
43
|
+
const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
|
|
44
|
+
if (filesystem !== 'read-only')
|
|
45
|
+
caps.push('write-state', 'workspace-edit');
|
|
46
|
+
return caps;
|
|
47
|
+
}
|
|
10
48
|
/** Resolve & validate the per-role permission mode, throwing on an unknown value. */
|
|
11
49
|
function permissionMode(role) {
|
|
12
50
|
const pm = role.harness_options?.permission_mode;
|
|
13
|
-
if (pm == null)
|
|
14
|
-
|
|
15
|
-
return 'dontAsk';
|
|
16
|
-
if (role.permissions?.approval === 'deny')
|
|
17
|
-
return 'plan';
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
51
|
+
if (pm == null)
|
|
52
|
+
return nativePermissionMode(role.permissions?.approval);
|
|
20
53
|
if (!PERMISSION_MODES.includes(pm))
|
|
21
54
|
throw new Error(`invalid harness_options.permission_mode "${pm}"; allowed: ${PERMISSION_MODES.join(', ')}`);
|
|
22
55
|
return pm;
|
|
@@ -33,16 +66,58 @@ export function autocompactPct(role) {
|
|
|
33
66
|
return 50;
|
|
34
67
|
return Math.max(1, Math.min(100, pct));
|
|
35
68
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Pre-trust a dir in ~/.claude.json so the first launch never blocks on the
|
|
71
|
+
* trust dialog.
|
|
72
|
+
*
|
|
73
|
+
* `~/.claude.json` is SHARED by every role and by the operator's own Claude
|
|
74
|
+
* Code. The previous read-modify-write held nothing while it worked, so two
|
|
75
|
+
* roles starting together interleaved and one silently lost its trust entry —
|
|
76
|
+
* and that role then blocked on the dialog it was supposed to be spared,
|
|
77
|
+
* unattended, with nobody to answer it. A crash mid-write truncated the file
|
|
78
|
+
* for everyone.
|
|
79
|
+
*
|
|
80
|
+
* Now: take a cross-process lock, re-read inside it, merge ONLY this project's
|
|
81
|
+
* entry so unrelated operator state survives untouched, and replace the file
|
|
82
|
+
* atomically. Never fatal — a role that cannot be pre-trusted still launches.
|
|
83
|
+
*/
|
|
84
|
+
export async function pretrust(dir, deps = {}) {
|
|
38
85
|
const p = join(home(), '.claude.json');
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
86
|
+
const log = deps.log ?? (() => { });
|
|
87
|
+
try {
|
|
88
|
+
await withFileLock(`${p}.lock`, () => {
|
|
89
|
+
let doc;
|
|
90
|
+
if (!existsSync(p))
|
|
91
|
+
doc = {};
|
|
92
|
+
else {
|
|
93
|
+
const raw = readFileSync(p, 'utf8');
|
|
94
|
+
try {
|
|
95
|
+
doc = JSON.parse(raw);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
// Someone else's file, and it is already broken. Overwriting it would
|
|
99
|
+
// destroy operator state we cannot read; refusing to launch would take
|
|
100
|
+
// the role down for a file it does not own.
|
|
101
|
+
log(`pretrust: ${p} is not valid JSON (${e.message}) — skipping pre-trust; `
|
|
102
|
+
+ `the role may block on Claude's trust dialog until the file is repaired`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
106
|
+
log(`pretrust: ${p} does not contain a JSON object — skipping pre-trust`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const projects = (doc.projects ??= {});
|
|
111
|
+
const e = (projects[dir] ??= {});
|
|
112
|
+
e.hasTrustDialogAccepted = true;
|
|
113
|
+
e.hasCompletedProjectOnboarding = true;
|
|
114
|
+
e.projectOnboardingSeenCount = Math.max(e.projectOnboardingSeenCount ?? 0, 1);
|
|
115
|
+
replaceFileAtomically(p, JSON.stringify(doc, null, 2));
|
|
116
|
+
}, deps.lock);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
log(`pretrust: could not update ${p} (${e.message}) — continuing without pre-trust`);
|
|
120
|
+
}
|
|
46
121
|
}
|
|
47
122
|
/**
|
|
48
123
|
* Shared Monitor-arming mandate (issue #16). Single-sourced so the briefing and
|
|
@@ -79,9 +154,12 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
79
154
|
.map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
|
|
80
155
|
},
|
|
81
156
|
async prepareSession(role, dirs) {
|
|
82
|
-
|
|
157
|
+
// Pre-trust stays a HOST-side step: inside the sandbox ~/.claude.json is
|
|
158
|
+
// read-only, and it is the fleet's job to trust the role's dirs, not the
|
|
159
|
+
// agent's (5.1, 6.1).
|
|
160
|
+
await pretrust(dirs.stateDir);
|
|
83
161
|
if (dirs.runCwd && dirs.runCwd !== dirs.stateDir)
|
|
84
|
-
pretrust(dirs.runCwd);
|
|
162
|
+
await pretrust(dirs.runCwd);
|
|
85
163
|
const o = (role.harness_options ?? {});
|
|
86
164
|
const memPalace = o.mem_palace !== false;
|
|
87
165
|
const enabledPlugins = { ...(o.plugins ?? {}) };
|
|
@@ -94,6 +172,12 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
94
172
|
};
|
|
95
173
|
if (!memPalace)
|
|
96
174
|
env.MEMPALACE_DISABLED = 'true';
|
|
175
|
+
// Per-role harness runtime home (5.1). Created before sandbox entry so the
|
|
176
|
+
// bind has something to mount; harmless for un-isolated roles.
|
|
177
|
+
// Only a role that declares `isolation:` gets a sandbox, and only a
|
|
178
|
+
// sandbox needs this directory to exist before entry.
|
|
179
|
+
if (role.isolation)
|
|
180
|
+
mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
|
|
97
181
|
const argv = [];
|
|
98
182
|
if (Object.keys(enabledPlugins).length) {
|
|
99
183
|
const overlay = join(dirs.stateDir, '.settings-overlay.json');
|
|
@@ -123,19 +207,37 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
123
207
|
: bundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp');
|
|
124
208
|
return { argv, env: prep.env };
|
|
125
209
|
},
|
|
210
|
+
isolationPaths(_role, _dirs) {
|
|
211
|
+
const claudeHome = join(home(), '.claude');
|
|
212
|
+
return {
|
|
213
|
+
home: claudeHome,
|
|
214
|
+
// Credentials and project trust (~/.claude.json), the global
|
|
215
|
+
// instructions every role shares, and the shared settings. Everything
|
|
216
|
+
// else under ~/.claude — sessions, projects, caches, history — is
|
|
217
|
+
// runtime state and belongs to the role, not to the fleet.
|
|
218
|
+
shared: [
|
|
219
|
+
join(home(), '.claude.json'),
|
|
220
|
+
join(claudeHome, 'CLAUDE.md'),
|
|
221
|
+
join(claudeHome, 'settings.json'),
|
|
222
|
+
join(claudeHome, 'plugins'),
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
nativePermissionOverrides(options) {
|
|
227
|
+
const pm = options?.permission_mode;
|
|
228
|
+
return pm == null ? {} : { permission_mode: pm };
|
|
229
|
+
},
|
|
126
230
|
translatePermissions(permissions) {
|
|
127
|
-
const native = permissions.approval
|
|
128
|
-
? 'dontAsk'
|
|
129
|
-
: permissions.approval === 'deny'
|
|
130
|
-
? 'plan'
|
|
131
|
-
: 'default';
|
|
231
|
+
const native = nativePermissionMode(permissions.approval) ?? 'default';
|
|
132
232
|
const exact = permissions.filesystem === 'workspace' && permissions.approval === 'ask';
|
|
133
233
|
return {
|
|
234
|
+
supported: true,
|
|
134
235
|
native: { permission_mode: native },
|
|
135
236
|
exact,
|
|
136
237
|
warnings: exact ? [] : [
|
|
137
238
|
'Claude permission modes do not exactly represent independent approval and filesystem intent; fleet isolation remains the outer boundary',
|
|
138
239
|
],
|
|
240
|
+
capabilities: claudeCapabilities(native, permissions.filesystem),
|
|
139
241
|
};
|
|
140
242
|
},
|
|
141
243
|
vocabulary: {
|
package/dist/harness/codex.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
|
-
import type { HarnessAdapter } from './types.js';
|
|
2
|
+
import type { HarnessAdapter, UnattendedCapability } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* What an unattended role can actually do under Codex's native settings.
|
|
5
|
+
* `on-request` and `untrusted` stop to ask, and with no console attached that
|
|
6
|
+
* request is refused rather than answered — so the role can only read.
|
|
7
|
+
*/
|
|
8
|
+
export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
|
|
3
9
|
export declare function makeCodexAdapter(exec?: Exec): HarnessAdapter;
|
|
4
10
|
export declare const codexAdapter: HarnessAdapter;
|
package/dist/harness/codex.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
|
-
import { agentDir } from '../paths.js';
|
|
3
|
+
import { agentDir, home } from '../paths.js';
|
|
3
4
|
import { realExec } from '../exec.js';
|
|
4
5
|
import { registerAdapter } from './registry.js';
|
|
6
|
+
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
5
7
|
import { bundledAcpAgent } from './acp-agent.js';
|
|
6
8
|
const OPTION_KEYS = [
|
|
7
9
|
'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
|
|
@@ -12,6 +14,19 @@ const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
|
|
|
12
14
|
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
13
15
|
/** Codex CLI's accepted `--ask-for-approval` values. */
|
|
14
16
|
const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
|
|
17
|
+
/**
|
|
18
|
+
* What an unattended role can actually do under Codex's native settings.
|
|
19
|
+
* `on-request` and `untrusted` stop to ask, and with no console attached that
|
|
20
|
+
* request is refused rather than answered — so the role can only read.
|
|
21
|
+
*/
|
|
22
|
+
export function codexCapabilities(approval, sandbox) {
|
|
23
|
+
if (approval !== 'never')
|
|
24
|
+
return ['read-state'];
|
|
25
|
+
const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
|
|
26
|
+
if (sandbox !== 'read-only')
|
|
27
|
+
caps.push('write-state', 'workspace-edit');
|
|
28
|
+
return caps;
|
|
29
|
+
}
|
|
15
30
|
/** Resolve & validate the per-role sandbox mode, throwing on an unknown value. */
|
|
16
31
|
function sandboxMode(role) {
|
|
17
32
|
const s = role.harness_options?.sandbox;
|
|
@@ -170,7 +185,12 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
170
185
|
}
|
|
171
186
|
return errs;
|
|
172
187
|
},
|
|
173
|
-
async prepareSession(role,
|
|
188
|
+
async prepareSession(role, dirs) {
|
|
189
|
+
// Per-role harness runtime home (5.1); harmless for un-isolated roles.
|
|
190
|
+
// Only a role that declares `isolation:` gets a sandbox, and only a
|
|
191
|
+
// sandbox needs this directory to exist before entry.
|
|
192
|
+
if (role.isolation)
|
|
193
|
+
mkdirSync(harnessRuntimeDir(dirs.stateDir, 'codex'), { recursive: true });
|
|
174
194
|
const requested = launcherMode(role);
|
|
175
195
|
const hasOursCodex = await commandAvailable('ours-codex', exec);
|
|
176
196
|
if (requested === 'ours-codex' && !hasOursCodex)
|
|
@@ -197,18 +217,45 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
197
217
|
: bundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp');
|
|
198
218
|
return { argv, env: prep.env };
|
|
199
219
|
},
|
|
220
|
+
isolationPaths(role, _dirs) {
|
|
221
|
+
const codexHome = join(home(), '.codex');
|
|
222
|
+
const profile = role.harness_options?.profile;
|
|
223
|
+
return {
|
|
224
|
+
home: codexHome,
|
|
225
|
+
// Credentials, shared config, shared instructions, and the role's own
|
|
226
|
+
// profile file if it names one. Sessions, history, caches and the local
|
|
227
|
+
// sqlite stores are runtime state and stay per-role.
|
|
228
|
+
shared: [
|
|
229
|
+
join(codexHome, 'auth.json'),
|
|
230
|
+
join(codexHome, 'config.toml'),
|
|
231
|
+
join(codexHome, 'AGENTS.md'),
|
|
232
|
+
join(codexHome, 'plugins'),
|
|
233
|
+
...(profile ? [join(codexHome, `${profile}.config.toml`)] : []),
|
|
234
|
+
join(home(), '.agents'),
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
nativePermissionOverrides(options) {
|
|
239
|
+
const o = options;
|
|
240
|
+
const approval = o?.approval ?? o?.permission_mode; // permission_mode is the alias
|
|
241
|
+
return {
|
|
242
|
+
...(approval == null ? {} : { approval }),
|
|
243
|
+
...(o?.sandbox == null ? {} : { sandbox: o.sandbox }),
|
|
244
|
+
};
|
|
245
|
+
},
|
|
200
246
|
translatePermissions(permissions) {
|
|
247
|
+
const approval = permissions.approval === 'allow' ? 'never' : 'on-request';
|
|
248
|
+
const sandbox = permissions.filesystem === 'read-only'
|
|
249
|
+
? 'read-only'
|
|
250
|
+
: permissions.filesystem === 'unrestricted'
|
|
251
|
+
? 'danger-full-access'
|
|
252
|
+
: 'workspace-write';
|
|
201
253
|
return {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
sandbox: permissions.filesystem === 'read-only'
|
|
205
|
-
? 'read-only'
|
|
206
|
-
: permissions.filesystem === 'unrestricted'
|
|
207
|
-
? 'danger-full-access'
|
|
208
|
-
: 'workspace-write',
|
|
209
|
-
},
|
|
254
|
+
supported: true,
|
|
255
|
+
native: { approval, sandbox },
|
|
210
256
|
exact: true,
|
|
211
257
|
warnings: [],
|
|
258
|
+
capabilities: codexCapabilities(approval, sandbox),
|
|
212
259
|
};
|
|
213
260
|
},
|
|
214
261
|
vocabulary: {
|
|
@@ -2,3 +2,5 @@ import type { HarnessAdapter } from './types.js';
|
|
|
2
2
|
export declare function registerAdapter(a: HarnessAdapter): void;
|
|
3
3
|
export declare function getAdapter(id: string): HarnessAdapter;
|
|
4
4
|
export declare function knownAdapters(): string[];
|
|
5
|
+
/** Production adapters actually registered in this process. */
|
|
6
|
+
export declare function productionAdapters(): string[];
|