@ours.network/fleet 0.9.4 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
|
@@ -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
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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',
|
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,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,
|
|
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[];
|
package/dist/harness/registry.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 };
|