@ours.network/fleet 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +148 -30
  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 +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
@@ -1,16 +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';
8
+ import { bundledAcpAgent } from './acp-agent.js';
6
9
  const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode'];
7
10
  /** Claude Code's accepted --permission-mode values. */
8
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
+ }
9
48
  /** Resolve & validate the per-role permission mode, throwing on an unknown value. */
10
49
  function permissionMode(role) {
11
50
  const pm = role.harness_options?.permission_mode;
12
51
  if (pm == null)
13
- return undefined;
52
+ return nativePermissionMode(role.permissions?.approval);
14
53
  if (!PERMISSION_MODES.includes(pm))
15
54
  throw new Error(`invalid harness_options.permission_mode "${pm}"; allowed: ${PERMISSION_MODES.join(', ')}`);
16
55
  return pm;
@@ -27,16 +66,58 @@ export function autocompactPct(role) {
27
66
  return 50;
28
67
  return Math.max(1, Math.min(100, pct));
29
68
  }
30
- /** Pre-trust a dir in ~/.claude.json so the first launch never blocks on the trust dialog. */
31
- 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 = {}) {
32
85
  const p = join(home(), '.claude.json');
33
- const d = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
34
- const projects = (d.projects ??= {});
35
- const e = (projects[dir] ??= {});
36
- e.hasTrustDialogAccepted = true;
37
- e.hasCompletedProjectOnboarding = true;
38
- e.projectOnboardingSeenCount = Math.max(e.projectOnboardingSeenCount ?? 0, 1);
39
- 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
+ }
40
121
  }
41
122
  /**
42
123
  * Shared Monitor-arming mandate (issue #16). Single-sourced so the briefing and
@@ -73,9 +154,12 @@ export function makeClaudeCodeAdapter(exec = realExec) {
73
154
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
74
155
  },
75
156
  async prepareSession(role, dirs) {
76
- 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);
77
161
  if (dirs.runCwd && dirs.runCwd !== dirs.stateDir)
78
- pretrust(dirs.runCwd);
162
+ await pretrust(dirs.runCwd);
79
163
  const o = (role.harness_options ?? {});
80
164
  const memPalace = o.mem_palace !== false;
81
165
  const enabledPlugins = { ...(o.plugins ?? {}) };
@@ -88,6 +172,12 @@ export function makeClaudeCodeAdapter(exec = realExec) {
88
172
  };
89
173
  if (!memPalace)
90
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 });
91
181
  const argv = [];
92
182
  if (Object.keys(enabledPlugins).length) {
93
183
  const overlay = join(dirs.stateDir, '.settings-overlay.json');
@@ -108,6 +198,48 @@ export function makeClaudeCodeAdapter(exec = realExec) {
108
198
  this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
109
199
  return { argv, env: prep.env };
110
200
  },
201
+ buildAcpLaunch(role, prep) {
202
+ const configured = role.session_options?.acp?.command;
203
+ const argv = Array.isArray(configured)
204
+ ? [...configured]
205
+ : typeof configured === 'string'
206
+ ? ['sh', '-c', configured]
207
+ : bundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp');
208
+ return { argv, env: prep.env };
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
+ },
230
+ translatePermissions(permissions) {
231
+ const native = nativePermissionMode(permissions.approval) ?? 'default';
232
+ const exact = permissions.filesystem === 'workspace' && permissions.approval === 'ask';
233
+ return {
234
+ supported: true,
235
+ native: { permission_mode: native },
236
+ exact,
237
+ warnings: exact ? [] : [
238
+ 'Claude permission modes do not exactly represent independent approval and filesystem intent; fleet isolation remains the outer boundary',
239
+ ],
240
+ capabilities: claudeCapabilities(native, permissions.filesystem),
241
+ };
242
+ },
111
243
  vocabulary: {
112
244
  bindTool: 'choose_identity',
113
245
  createTool: 'create_identity',
@@ -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,10 @@
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';
7
+ import { bundledAcpAgent } from './acp-agent.js';
5
8
  const OPTION_KEYS = [
6
9
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
7
10
  'monitor',
@@ -11,11 +14,32 @@ const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
11
14
  const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
12
15
  /** Codex CLI's accepted `--ask-for-approval` values. */
13
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
+ }
14
30
  /** Resolve & validate the per-role sandbox mode, throwing on an unknown value. */
15
31
  function sandboxMode(role) {
16
32
  const s = role.harness_options?.sandbox;
17
- if (s == null)
33
+ if (s == null) {
34
+ const filesystem = role.permissions?.filesystem;
35
+ if (filesystem === 'read-only')
36
+ return 'read-only';
37
+ if (filesystem === 'unrestricted')
38
+ return 'danger-full-access';
39
+ if (filesystem === 'workspace')
40
+ return 'workspace-write';
18
41
  return undefined;
42
+ }
19
43
  if (!SANDBOX_MODES.includes(s))
20
44
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
21
45
  return s;
@@ -24,8 +48,14 @@ function sandboxMode(role) {
24
48
  function approvalPolicy(role) {
25
49
  const o = role.harness_options;
26
50
  const a = o?.approval ?? o?.permission_mode;
27
- if (a == null)
51
+ if (a == null) {
52
+ const approval = role.permissions?.approval;
53
+ if (approval === 'allow')
54
+ return 'never';
55
+ if (approval === 'ask' || approval === 'deny')
56
+ return 'on-request';
28
57
  return undefined;
58
+ }
29
59
  if (!APPROVAL_POLICIES.includes(a))
30
60
  throw new Error(`invalid harness_options.approval "${a}"; allowed: ${APPROVAL_POLICIES.join(', ')}`);
31
61
  return a;
@@ -155,7 +185,12 @@ export function makeCodexAdapter(exec = realExec) {
155
185
  }
156
186
  return errs;
157
187
  },
158
- 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 });
159
194
  const requested = launcherMode(role);
160
195
  const hasOursCodex = await commandAvailable('ours-codex', exec);
161
196
  if (requested === 'ours-codex' && !hasOursCodex)
@@ -173,6 +208,56 @@ export function makeCodexAdapter(exec = realExec) {
173
208
  this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
174
209
  return { argv, env: prep.env };
175
210
  },
211
+ buildAcpLaunch(role, prep) {
212
+ const configured = role.session_options?.acp?.command;
213
+ const argv = Array.isArray(configured)
214
+ ? [...configured]
215
+ : typeof configured === 'string'
216
+ ? ['sh', '-c', configured]
217
+ : bundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp');
218
+ return { argv, env: prep.env };
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
+ },
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';
253
+ return {
254
+ supported: true,
255
+ native: { approval, sandbox },
256
+ exact: true,
257
+ warnings: [],
258
+ capabilities: codexCapabilities(approval, sandbox),
259
+ };
260
+ },
176
261
  vocabulary: {
177
262
  bindTool: 'choose_identity',
178
263
  createTool: 'create_identity',
@@ -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[];
@@ -1,5 +1,14 @@
1
1
  const adapters = new Map();
2
2
  export function registerAdapter(a) {
3
+ // Enforced here rather than left to the type system: an adapter that silently
4
+ // omits the capability is how the neutral-permission warnings ended up with no
5
+ // caller and no reader.
6
+ if (typeof a.translatePermissions !== 'function')
7
+ throw new Error(`harness adapter '${a.id}' must implement translatePermissions(): either translate ` +
8
+ `neutral permissions or return { supported: false, reason }`);
9
+ if (typeof a.nativePermissionOverrides !== 'function')
10
+ throw new Error(`harness adapter '${a.id}' must implement nativePermissionOverrides(): report the ` +
11
+ `native permission settings a role states in harness_options, or {}`);
3
12
  adapters.set(a.id, a);
4
13
  }
5
14
  export function getAdapter(id) {
@@ -11,3 +20,13 @@ export function getAdapter(id) {
11
20
  export function knownAdapters() {
12
21
  return [...adapters.keys()];
13
22
  }
23
+ /**
24
+ * The adapters ours-fleet ships. Tests register extras, so "everything in the
25
+ * registry" is not the same question — this is the set doctor falls back to
26
+ * when a broken configuration names no harness at all.
27
+ */
28
+ const PRODUCTION_ADAPTERS = ['claude-code', 'codex'];
29
+ /** Production adapters actually registered in this process. */
30
+ export function productionAdapters() {
31
+ return PRODUCTION_ADAPTERS.filter(id => adapters.has(id));
32
+ }
@@ -1,4 +1,4 @@
1
- import type { ResolvedRole } from '../config.js';
1
+ import type { CommonPermissions, ResolvedRole } from '../config.js';
2
2
  export interface PrereqCheck {
3
3
  name: string;
4
4
  ok: boolean;
@@ -26,6 +26,36 @@ export interface Launch {
26
26
  argv: string[];
27
27
  env: Record<string, string>;
28
28
  }
29
+ export interface AcpLaunch {
30
+ argv: string[];
31
+ env: Record<string, string>;
32
+ }
33
+ /**
34
+ * The result of expressing neutral `permissions:` in a harness's own terms.
35
+ *
36
+ * `supported: false` is a first-class answer, not an absence. The previous
37
+ * shape made `translatePermissions` optional, so an adapter that simply never
38
+ * implemented it was indistinguishable from one that had nothing to say — and
39
+ * the warnings the implementations DID produce had no caller at all.
40
+ */
41
+ /**
42
+ * What an unattended agent must actually be able to DO to run its own briefing.
43
+ * A role that cannot meet this floor does not fail loudly — it silently does
44
+ * less than it was asked to, because the denial happens inside the harness with
45
+ * nobody to see it.
46
+ */
47
+ export type UnattendedCapability = 'read-state' | 'write-state' | 'messaging' | 'monitor' | 'workspace-edit' | 'status-commands';
48
+ export type PermissionTranslation = {
49
+ supported: true;
50
+ native: Record<string, unknown>;
51
+ exact: boolean;
52
+ warnings: string[];
53
+ /** What the native settings above actually permit, unattended. */
54
+ capabilities: UnattendedCapability[];
55
+ } | {
56
+ supported: false;
57
+ reason: string;
58
+ };
29
59
  /** Harness-correct wording/tool names used to generate briefing.md. */
30
60
  export interface BriefingVocab {
31
61
  bindTool: string;
@@ -42,6 +72,16 @@ export interface BriefingVocab {
42
72
  launchNote(name: string): string;
43
73
  restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
44
74
  }
75
+ /**
76
+ * How a harness's host state splits for sandboxing (5.1). `home` is the
77
+ * directory the CLI treats as its own and whose RUNTIME state must be per-role;
78
+ * `shared` are the credential, instruction and configuration paths that stay
79
+ * shared and become read-only inside the sandbox.
80
+ */
81
+ export interface HarnessIsolationPaths {
82
+ home?: string;
83
+ shared: string[];
84
+ }
45
85
  export interface ExitPolicy {
46
86
  cleanExitIsFresh: boolean;
47
87
  fastFailSecs: number;
@@ -57,6 +97,24 @@ export interface HarnessAdapter {
57
97
  validateOptions(opts: unknown): ValidationError[];
58
98
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
59
99
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
100
+ buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
101
+ /**
102
+ * REQUIRED. Every adapter must either translate neutral permissions or
103
+ * explicitly declare that it cannot. Enforced at registration.
104
+ */
105
+ translatePermissions(permissions: CommonPermissions): PermissionTranslation;
106
+ /**
107
+ * The permission settings this role states NATIVELY in `harness_options`,
108
+ * keyed the same way `translatePermissions().native` is, so the two can be
109
+ * compared directly. Only keys the operator actually wrote appear.
110
+ */
111
+ nativePermissionOverrides(options: unknown): Record<string, unknown>;
112
+ /**
113
+ * Host paths this harness needs inside a sandbox, split into a per-role
114
+ * writable home and shared read-only credentials/config (5.1). Omit for a
115
+ * harness with no host state of its own.
116
+ */
117
+ isolationPaths?(role: ResolvedRole, dirs: RoleDirs): HarnessIsolationPaths;
60
118
  vocabulary: BriefingVocab;
61
119
  exitPolicy: ExitPolicy;
62
120
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- export { loadConfig, findRole, ConfigError } from './config.js';
2
- export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry } from './config.js';
3
- export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, RoleDirs, ValidationError, } from './harness/types.js';
1
+ export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
+ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, } from './config.js';
3
+ export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
4
+ export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
5
+ export { AcpSession } from './session/acp.js';
6
+ export { TmuxSession } from './session/tmux.js';
4
7
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
5
8
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
6
9
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
- export { loadConfig, findRole, ConfigError } from './config.js';
1
+ export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
+ export { AcpSession } from './session/acp.js';
3
+ export { TmuxSession } from './session/tmux.js';
2
4
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
3
5
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
4
6
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
@@ -8,7 +8,13 @@ import { realExec } from '../exec.js';
8
8
  export function unsharesNet(network) {
9
9
  return network === 'deny';
10
10
  }
11
- /** Build the `bwrap … -- <argv>` sandbox launcher argv. Pure — no I/O. */
11
+ /**
12
+ * Build the `bwrap … -- <argv>` sandbox launcher argv. Pure — no I/O.
13
+ *
14
+ * Consumes an ALREADY-ENFORCED mount set: `resolveIsolation` has refused
15
+ * anything that breaches the forbidden-path list, so no forbidden path can
16
+ * reach this argv. This function must never add a mount of its own.
17
+ */
12
18
  function wrap(argv, policy, ctx) {
13
19
  const out = [
14
20
  'bwrap',
@@ -1,17 +1,46 @@
1
1
  import { type IsolationConfig, type ResolvedIsolation, type WrapContext } from './types.js';
2
+ /** A mount that the forbidden-path policy refuses. Raised before any launch. */
3
+ export declare class IsolationPolicyError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /**
7
+ * Resolve a path to its canonical form, following symlinks as far as the
8
+ * filesystem allows and normalising the rest. Without this, `~/link-to-ssh`
9
+ * and `/home/u/.ssh` are different strings for the same directory, and a
10
+ * string comparison against the forbidden list is trivially side-stepped.
11
+ */
12
+ export declare function canonicalPath(p: string): string;
13
+ /**
14
+ * How a canonical mount path collides with a canonical forbidden path.
15
+ *
16
+ * `parent` matters as much as the other two: binding `$HOME` does not name
17
+ * `~/.ssh`, but it exposes it just as completely.
18
+ */
19
+ export declare function mountConflict(mount: string, forbidden: string): 'exact' | 'descendant' | 'parent' | null;
2
20
  /**
3
21
  * Validate a raw `isolation:` block. Returns a list of human-readable problems
4
22
  * (empty ⇒ valid). Pure; callable from config.ts like adapter.validateOptions.
5
23
  */
6
24
  export declare function validateIsolationConfig(raw: unknown): string[];
25
+ /**
26
+ * Where a role's per-role harness runtime state lives (5.1). Under the agent's
27
+ * own state directory, so it is covered by the state dir's existing lifecycle
28
+ * and by the forbidden-path exception, and is never shared with a peer.
29
+ */
30
+ export declare const harnessRuntimeDir: (stateDir: string, harnessId: string) => string;
7
31
  /**
8
32
  * Resolve a raw (already validated) isolation block against runtime context into
9
- * a defaults-filled, backend-agnostic policy. Pure no I/O, no probing.
33
+ * a defaults-filled, backend-agnostic policy, and REFUSE any mount that would
34
+ * breach the forbidden-path list.
35
+ *
36
+ * The mount model is an allowlist: only the durable set (state dir, cwd, harness
37
+ * config, declared fs/secrets) plus read-only system dirs are exposed. The
38
+ * forbidden list — the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws
39
+ * — is now enforced on top of that, so a role cannot ask its way back in.
10
40
  *
11
- * The mount model is an allowlist: only the durable set (state dir, cwd, Claude
12
- * config, declared fs/secrets) plus read-only system dirs are exposed; everything
13
- * else on the host the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws —
14
- * is simply never mounted, and thus absent inside the sandbox (§5.2).
41
+ * Not pure: canonicalising a path reads the filesystem, because symlink aliases
42
+ * are one of the ways a forbidden path gets requested. Throws
43
+ * `IsolationPolicyError`; callers surface it against the role.
15
44
  */
16
45
  export declare function resolveIsolation(cfg: IsolationConfig, ctx: WrapContext): ResolvedIsolation;
17
46
  export type { IsolationConfig };