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