@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.
Files changed (53) hide show
  1. package/README.md +101 -0
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +95 -21
  7. package/dist/config.d.ts +15 -1
  8. package/dist/config.js +47 -2
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +132 -0
  13. package/dist/doctor.js +74 -16
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +126 -24
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +57 -10
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +50 -3
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +30 -3
  28. package/dist/monitor.js +63 -25
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +65 -2
  34. package/dist/runner.js +239 -19
  35. package/dist/session/acp.d.ts +22 -1
  36. package/dist/session/acp.js +110 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +8 -1
  40. package/dist/session/tmux.js +34 -4
  41. package/dist/session/types.d.ts +92 -1
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. 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 { agentDir, home, deriveXdgRuntimeDir } from './paths.js';
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
- const roles = loadConfigSafe(opts.configPath);
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
- const stateDir = agentDir(r.name);
102
- const policy = resolveIsolation(r.isolation, {
103
- stateDir, runCwd: r.cwd ?? stateDir, home: home(), harness: r.harness,
104
- additionalWriteDirs: r.harness === 'codex'
105
- ? (r.harness_options?.add_dirs ?? [])
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
- : [...new Set(roles.map(r => r.harness))];
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 loadConfigSafe(configPath) {
275
+ function loadConfigResult(configPath) {
219
276
  try {
220
- return loadConfig(configPath).roles;
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
- /** Pre-trust a dir in ~/.claude.json so the first launch never blocks on the trust dialog. */
6
- export declare function pretrust(dir: string): void;
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
- if (role.permissions?.approval === 'allow')
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
- /** Pre-trust a dir in ~/.claude.json so the first launch never blocks on the trust dialog. */
37
- export function pretrust(dir) {
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 d = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
40
- const projects = (d.projects ??= {});
41
- const e = (projects[dir] ??= {});
42
- e.hasTrustDialogAccepted = true;
43
- e.hasCompletedProjectOnboarding = true;
44
- e.projectOnboardingSeenCount = Math.max(e.projectOnboardingSeenCount ?? 0, 1);
45
- writeFileSync(p, JSON.stringify(d, null, 2));
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
- pretrust(dirs.stateDir);
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 === 'allow'
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: {
@@ -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;
@@ -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, _dirs) {
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
- native: {
203
- approval: permissions.approval === 'allow' ? 'never' : 'on-request',
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[];