@ours.network/fleet 0.2.0 → 0.5.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 +15 -4
- package/dist/briefing.d.ts +1 -0
- package/dist/briefing.js +4 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/harness/claude-code.js +14 -1
- package/dist/isolation/bubblewrap.d.ts +10 -0
- package/dist/isolation/bubblewrap.js +57 -0
- package/dist/isolation/none.d.ts +4 -0
- package/dist/isolation/none.js +9 -0
- package/dist/isolation/policy.d.ts +17 -0
- package/dist/isolation/policy.js +110 -0
- package/dist/isolation/registry.d.ts +24 -0
- package/dist/isolation/registry.js +36 -0
- package/dist/isolation/resources.d.ts +17 -0
- package/dist/isolation/resources.js +58 -0
- package/dist/isolation/types.d.ts +80 -0
- package/dist/isolation/types.js +3 -0
- package/dist/ops.js +2 -1
- package/dist/runner.d.ts +11 -2
- package/dist/runner.js +37 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,10 +49,19 @@ roles:
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Each role gets a state dir (`~/.ours-fleet/agents/<Name>/`) holding its briefing,
|
|
52
|
-
|
|
53
|
-
publish bio/persona, arm a mail monitor (`ours-mcp watch`), announce to
|
|
54
|
-
coordinator, work. On crash the supervisor relaunches it and the harness
|
|
55
|
-
the same session.
|
|
52
|
+
logs, routines, and session markers. On boot the agent reads its briefing: bind
|
|
53
|
+
identity, publish bio/persona, arm a mail monitor (`ours-mcp watch`), announce to
|
|
54
|
+
its coordinator, work. On crash the supervisor relaunches it and the harness
|
|
55
|
+
resumes the same session.
|
|
56
|
+
|
|
57
|
+
The state dir contract:
|
|
58
|
+
|
|
59
|
+
| File | Owner | Lifecycle |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `briefing.md` | generated | rewritten on every `up`/`restart`; never hand-edit |
|
|
62
|
+
| `WORKLOG.md` | the agent | seeded empty, agent-appended; survives restarts |
|
|
63
|
+
| `ROUTINES.md` | operator / agent | **optional** recurring-work instructions; re-read at the start of every wake, hot-editable **without a restart**; absence means "no routines" |
|
|
64
|
+
| `.identity`, `.cwd`, `.session-id`, `.booted`, `.exit-status` | supervisor | dot-marker state — session resume and boot bookkeeping |
|
|
56
65
|
|
|
57
66
|
## Prerequisites
|
|
58
67
|
|
|
@@ -167,6 +176,8 @@ roles:
|
|
|
167
176
|
harness_options: # adapter-owned, adapter-validated
|
|
168
177
|
plugins: { "name@marketplace": false } # claude-code: plugin overrides
|
|
169
178
|
# mem_palace: false # claude-code: disable memory plugin
|
|
179
|
+
# permission_mode: dontAsk # claude-code: launch permission mode —
|
|
180
|
+
# one of default | acceptEdits | plan | dontAsk | bypassPermissions
|
|
170
181
|
```
|
|
171
182
|
|
|
172
183
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
package/dist/briefing.d.ts
CHANGED
package/dist/briefing.js
CHANGED
|
@@ -62,6 +62,10 @@ export function generateBriefing(role, v, opts) {
|
|
|
62
62
|
L.push('', '## Durable log');
|
|
63
63
|
L.push(`Append important commands / decisions / results to \`${opts.worklogPath}\` as you go —`);
|
|
64
64
|
L.push('it survives restarts.');
|
|
65
|
+
L.push('', '## Routines');
|
|
66
|
+
L.push(`If \`${opts.routinesPath}\` exists, re-read it at the START of every wake — before acting`);
|
|
67
|
+
L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
|
|
68
|
+
L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
|
|
65
69
|
L.push('', '## On restart (you run under a supervised launcher)');
|
|
66
70
|
L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true),`);
|
|
67
71
|
L.push(`re-arm the \`${v.watchCommand(id)}\` monitor, then continue from your WORKLOG.`);
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IsolationConfig } from './isolation/types.js';
|
|
1
2
|
export interface OverseeEntry {
|
|
2
3
|
role: string;
|
|
3
4
|
interval: string;
|
|
@@ -17,6 +18,7 @@ export interface RoleConfig {
|
|
|
17
18
|
env?: Record<string, string>;
|
|
18
19
|
oversee?: OverseeEntry[];
|
|
19
20
|
harness_options?: Record<string, unknown>;
|
|
21
|
+
isolation?: IsolationConfig;
|
|
20
22
|
}
|
|
21
23
|
export interface ResolvedRole extends RoleConfig {
|
|
22
24
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -2,12 +2,14 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import { defaultConfigPath, fleetDDir } from './paths.js';
|
|
5
|
+
import { validateIsolationConfig } from './isolation/policy.js';
|
|
5
6
|
export class ConfigError extends Error {
|
|
6
7
|
}
|
|
7
8
|
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
8
9
|
const ROLE_KEYS = [
|
|
9
10
|
'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
10
11
|
'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
|
|
12
|
+
'isolation',
|
|
11
13
|
];
|
|
12
14
|
function deepSub(v, vars) {
|
|
13
15
|
if (typeof v === 'string')
|
|
@@ -59,6 +61,12 @@ export function loadConfig(configPath) {
|
|
|
59
61
|
const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
|
|
60
62
|
if (bad.length)
|
|
61
63
|
throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
|
|
64
|
+
const isolation = r.isolation ?? defaults.isolation;
|
|
65
|
+
if (isolation !== undefined) {
|
|
66
|
+
const problems = validateIsolationConfig(isolation);
|
|
67
|
+
if (problems.length)
|
|
68
|
+
throw new ConfigError(`${file}: role '${name}' ${problems.join('; ')}`);
|
|
69
|
+
}
|
|
62
70
|
roles.push({
|
|
63
71
|
...r,
|
|
64
72
|
name,
|
|
@@ -67,6 +75,7 @@ export function loadConfig(configPath) {
|
|
|
67
75
|
identity: r.identity ?? name,
|
|
68
76
|
model: r.model ?? defaults.model,
|
|
69
77
|
max_tokens: r.max_tokens ?? defaults.max_tokens,
|
|
78
|
+
isolation,
|
|
70
79
|
});
|
|
71
80
|
}
|
|
72
81
|
}
|
|
@@ -3,7 +3,18 @@ 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
|
-
const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave'];
|
|
6
|
+
const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode'];
|
|
7
|
+
/** Claude Code's accepted --permission-mode values. */
|
|
8
|
+
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
|
|
9
|
+
/** Resolve & validate the per-role permission mode, throwing on an unknown value. */
|
|
10
|
+
function permissionMode(role) {
|
|
11
|
+
const pm = role.harness_options?.permission_mode;
|
|
12
|
+
if (pm == null)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (!PERMISSION_MODES.includes(pm))
|
|
15
|
+
throw new Error(`invalid harness_options.permission_mode "${pm}"; allowed: ${PERMISSION_MODES.join(', ')}`);
|
|
16
|
+
return pm;
|
|
17
|
+
}
|
|
7
18
|
/** Context window of the fleet model (1M); max_tokens → % of this. */
|
|
8
19
|
const WINDOW = 1_000_000;
|
|
9
20
|
export function autocompactPct(role) {
|
|
@@ -78,7 +89,9 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
78
89
|
},
|
|
79
90
|
buildLaunch(role, mode, s, prep) {
|
|
80
91
|
const stateDir = roleStateDir(role);
|
|
92
|
+
const pm = permissionMode(role);
|
|
81
93
|
const base = ['claude', ...(role.model ? ['--model', role.model] : []),
|
|
94
|
+
...(pm ? ['--permission-mode', pm] : []),
|
|
82
95
|
...prep.argv, '--remote-control', role.name];
|
|
83
96
|
const argv = mode === 'fresh'
|
|
84
97
|
? [...base, '--session-id', s.sessionId, `Read and follow ${join(stateDir, 'briefing.md')} now.`]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { IsolationBackend, NetworkMode } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Phase 2 network policy: only `deny` unshares the network namespace. `broker`
|
|
5
|
+
* keeps the host network so ours messaging (a loopback TCP daemon on this host —
|
|
6
|
+
* see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
|
|
7
|
+
* a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
|
|
8
|
+
*/
|
|
9
|
+
export declare function unsharesNet(network: NetworkMode): boolean;
|
|
10
|
+
export declare function makeBubblewrapBackend(exec?: Exec): IsolationBackend;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { realExec } from '../exec.js';
|
|
2
|
+
/**
|
|
3
|
+
* Phase 2 network policy: only `deny` unshares the network namespace. `broker`
|
|
4
|
+
* keeps the host network so ours messaging (a loopback TCP daemon on this host —
|
|
5
|
+
* see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
|
|
6
|
+
* a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
|
|
7
|
+
*/
|
|
8
|
+
export function unsharesNet(network) {
|
|
9
|
+
return network === 'deny';
|
|
10
|
+
}
|
|
11
|
+
/** Build the `bwrap … -- <argv>` sandbox launcher argv. Pure — no I/O. */
|
|
12
|
+
function wrap(argv, policy, ctx) {
|
|
13
|
+
const out = [
|
|
14
|
+
'bwrap',
|
|
15
|
+
'--die-with-parent',
|
|
16
|
+
'--unshare-user', '--unshare-ipc', '--unshare-uts', '--unshare-pid',
|
|
17
|
+
'--proc', '/proc',
|
|
18
|
+
'--dev', '/dev',
|
|
19
|
+
'--chdir', ctx.runCwd,
|
|
20
|
+
];
|
|
21
|
+
// Read-only system dirs (allowlist model): best-effort so a missing /lib64 etc.
|
|
22
|
+
// does not abort the launch.
|
|
23
|
+
for (const s of policy.system)
|
|
24
|
+
out.push('--ro-bind-try', s, s);
|
|
25
|
+
// Ephemeral scratch.
|
|
26
|
+
for (const t of policy.tmpfs)
|
|
27
|
+
out.push('--tmpfs', t);
|
|
28
|
+
// Durable + declared binds. State dir and cwd are runner-guaranteed → hard binds
|
|
29
|
+
// (fail loud if absent); everything else is best-effort.
|
|
30
|
+
for (const m of policy.mounts) {
|
|
31
|
+
const hard = m.src === ctx.stateDir || m.src === ctx.runCwd;
|
|
32
|
+
const flag = m.mode === 'rw'
|
|
33
|
+
? (hard ? '--bind' : '--bind-try')
|
|
34
|
+
: (hard ? '--ro-bind' : '--ro-bind-try');
|
|
35
|
+
out.push(flag, m.src, m.dst);
|
|
36
|
+
}
|
|
37
|
+
if (unsharesNet(policy.network))
|
|
38
|
+
out.push('--unshare-net');
|
|
39
|
+
out.push('--', ...argv);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export function makeBubblewrapBackend(exec = realExec) {
|
|
43
|
+
return {
|
|
44
|
+
id: 'bubblewrap',
|
|
45
|
+
async available() {
|
|
46
|
+
const v = await exec('bwrap', ['--version']);
|
|
47
|
+
if (v.code !== 0)
|
|
48
|
+
return { ok: false, detail: 'bubblewrap (bwrap) not found on PATH' };
|
|
49
|
+
// userns smoke test: a no-op sandbox that actually creates the namespaces.
|
|
50
|
+
const smoke = await exec('bwrap', ['--ro-bind', '/', '/', '--unshare-user', '--unshare-net', '--', 'true']);
|
|
51
|
+
if (smoke.code !== 0)
|
|
52
|
+
return { ok: false, detail: `bwrap userns smoke test failed: ${smoke.stderr.trim() || `exit ${smoke.code}`}` };
|
|
53
|
+
return { ok: true, detail: v.stdout.trim() };
|
|
54
|
+
},
|
|
55
|
+
wrap,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { IsolationBackend } from './types.js';
|
|
2
|
+
/** The identity backend: no sandbox. wrap() returns the argv unchanged. Used for
|
|
3
|
+
* `backend: none` and as the fail-open target when `on_unavailable: warn`. */
|
|
4
|
+
export declare function makeNoneBackend(): IsolationBackend;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The identity backend: no sandbox. wrap() returns the argv unchanged. Used for
|
|
2
|
+
* `backend: none` and as the fail-open target when `on_unavailable: warn`. */
|
|
3
|
+
export function makeNoneBackend() {
|
|
4
|
+
return {
|
|
5
|
+
id: 'none',
|
|
6
|
+
async available() { return { ok: true, detail: 'no isolation (identity backend)' }; },
|
|
7
|
+
wrap(argv) { return argv; },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type IsolationConfig, type ResolvedIsolation, type WrapContext } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validate a raw `isolation:` block. Returns a list of human-readable problems
|
|
4
|
+
* (empty ⇒ valid). Pure; callable from config.ts like adapter.validateOptions.
|
|
5
|
+
*/
|
|
6
|
+
export declare function validateIsolationConfig(raw: unknown): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a raw (already validated) isolation block against runtime context into
|
|
9
|
+
* a defaults-filled, backend-agnostic policy. Pure — no I/O, no probing.
|
|
10
|
+
*
|
|
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).
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveIsolation(cfg: IsolationConfig, ctx: WrapContext): ResolvedIsolation;
|
|
17
|
+
export type { IsolationConfig };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { BACKENDS, ON_UNAVAILABLE, NETWORK_MODES, } from './types.js';
|
|
3
|
+
/** Read-only system dirs exposed under the allowlist model. */
|
|
4
|
+
const SYSTEM_RO = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
|
|
5
|
+
/** Ephemeral scratch mounts. */
|
|
6
|
+
const scratchTmpfs = (home) => ['/tmp', join(home, '.cache')];
|
|
7
|
+
/** Home-relative sensitive paths never exposed (the blocklist's teeth). */
|
|
8
|
+
const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', 'fleet.yaml', 'fleet.d'];
|
|
9
|
+
const ISOLATION_KEYS = ['backend', 'on_unavailable', 'fs', 'network', 'allow_hosts', 'resources', 'secrets'];
|
|
10
|
+
const FS_KEYS = ['read', 'write'];
|
|
11
|
+
const RESOURCE_KEYS = ['cpu', 'mem', 'pids'];
|
|
12
|
+
const unknownKeys = (obj, allowed) => Object.keys(obj).filter(k => !allowed.includes(k));
|
|
13
|
+
const enumProblem = (label, value, allowed) => value === undefined || allowed.includes(value)
|
|
14
|
+
? null
|
|
15
|
+
: `${label}: invalid value '${value}'; allowed: ${allowed.join(', ')}`;
|
|
16
|
+
/**
|
|
17
|
+
* Validate a raw `isolation:` block. Returns a list of human-readable problems
|
|
18
|
+
* (empty ⇒ valid). Pure; callable from config.ts like adapter.validateOptions.
|
|
19
|
+
*/
|
|
20
|
+
export function validateIsolationConfig(raw) {
|
|
21
|
+
const problems = [];
|
|
22
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
23
|
+
return ['isolation: must be a mapping'];
|
|
24
|
+
const iso = raw;
|
|
25
|
+
const bad = unknownKeys(iso, ISOLATION_KEYS);
|
|
26
|
+
if (bad.length)
|
|
27
|
+
problems.push(`isolation: unknown key(s) ${bad.join(', ')}; allowed: ${ISOLATION_KEYS.join(', ')}`);
|
|
28
|
+
for (const p of [
|
|
29
|
+
enumProblem('isolation.backend', iso.backend, BACKENDS),
|
|
30
|
+
enumProblem('isolation.on_unavailable', iso.on_unavailable, ON_UNAVAILABLE),
|
|
31
|
+
enumProblem('isolation.network', iso.network, NETWORK_MODES),
|
|
32
|
+
])
|
|
33
|
+
if (p)
|
|
34
|
+
problems.push(p);
|
|
35
|
+
if (iso.fs !== undefined) {
|
|
36
|
+
if (typeof iso.fs !== 'object' || iso.fs === null || Array.isArray(iso.fs))
|
|
37
|
+
problems.push('isolation.fs: must be a mapping');
|
|
38
|
+
else {
|
|
39
|
+
const fsBad = unknownKeys(iso.fs, FS_KEYS);
|
|
40
|
+
if (fsBad.length)
|
|
41
|
+
problems.push(`isolation.fs: unknown key(s) ${fsBad.join(', ')}; allowed: ${FS_KEYS.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (iso.resources !== undefined) {
|
|
45
|
+
if (typeof iso.resources !== 'object' || iso.resources === null || Array.isArray(iso.resources))
|
|
46
|
+
problems.push('isolation.resources: must be a mapping');
|
|
47
|
+
else {
|
|
48
|
+
const rBad = unknownKeys(iso.resources, RESOURCE_KEYS);
|
|
49
|
+
if (rBad.length)
|
|
50
|
+
problems.push(`isolation.resources: unknown key(s) ${rBad.join(', ')}; allowed: ${RESOURCE_KEYS.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return problems;
|
|
54
|
+
}
|
|
55
|
+
/** Parse a `host:container` secret pair; a bare path maps to itself. */
|
|
56
|
+
function parseSecret(pair) {
|
|
57
|
+
const i = pair.indexOf(':');
|
|
58
|
+
const src = i === -1 ? pair : pair.slice(0, i);
|
|
59
|
+
const dst = i === -1 ? pair : pair.slice(i + 1);
|
|
60
|
+
return { src, dst, mode: 'ro' };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a raw (already validated) isolation block against runtime context into
|
|
64
|
+
* a defaults-filled, backend-agnostic policy. Pure — no I/O, no probing.
|
|
65
|
+
*
|
|
66
|
+
* The mount model is an allowlist: only the durable set (state dir, cwd, Claude
|
|
67
|
+
* config, declared fs/secrets) plus read-only system dirs are exposed; everything
|
|
68
|
+
* else on the host — the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws —
|
|
69
|
+
* is simply never mounted, and thus absent inside the sandbox (§5.2).
|
|
70
|
+
*/
|
|
71
|
+
export function resolveIsolation(cfg, ctx) {
|
|
72
|
+
const { stateDir, runCwd, home } = ctx;
|
|
73
|
+
const mounts = [];
|
|
74
|
+
const addRw = (p) => { if (!mounts.some(m => m.src === p))
|
|
75
|
+
mounts.push({ src: p, dst: p, mode: 'rw' }); };
|
|
76
|
+
const addRo = (p) => { if (!mounts.some(m => m.src === p))
|
|
77
|
+
mounts.push({ src: p, dst: p, mode: 'ro' }); };
|
|
78
|
+
// Durable set (always present, rw): state dir, cwd, Claude config.
|
|
79
|
+
addRw(stateDir);
|
|
80
|
+
addRw(runCwd);
|
|
81
|
+
addRw(join(home, '.claude'));
|
|
82
|
+
addRw(join(home, '.claude.json'));
|
|
83
|
+
// Declared fs extras.
|
|
84
|
+
for (const p of cfg.fs?.write ?? [])
|
|
85
|
+
addRw(p);
|
|
86
|
+
for (const p of cfg.fs?.read ?? [])
|
|
87
|
+
addRo(p);
|
|
88
|
+
// Declared secrets (ro, host:container).
|
|
89
|
+
for (const pair of cfg.secrets ?? []) {
|
|
90
|
+
const m = parseSecret(pair);
|
|
91
|
+
if (!mounts.some(x => x.src === m.src && x.dst === m.dst))
|
|
92
|
+
mounts.push(m);
|
|
93
|
+
}
|
|
94
|
+
const agentsRoot = dirname(stateDir);
|
|
95
|
+
const blocklist = [
|
|
96
|
+
...SENSITIVE_HOME.map(p => join(home, p)),
|
|
97
|
+
agentsRoot, // sibling agents' state dirs (this agent's own is explicitly mounted)
|
|
98
|
+
];
|
|
99
|
+
return {
|
|
100
|
+
backend: cfg.backend ?? 'auto',
|
|
101
|
+
onUnavailable: cfg.on_unavailable ?? 'warn',
|
|
102
|
+
network: cfg.network ?? 'broker',
|
|
103
|
+
allowHosts: cfg.allow_hosts ?? [],
|
|
104
|
+
resources: cfg.resources ?? {},
|
|
105
|
+
mounts,
|
|
106
|
+
system: SYSTEM_RO,
|
|
107
|
+
tmpfs: scratchTmpfs(home),
|
|
108
|
+
blocklist,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { IsolationBackend, ResolvedIsolation } from './types.js';
|
|
3
|
+
export type { IsolationBackend } from './types.js';
|
|
4
|
+
export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
|
|
5
|
+
export { makeNoneBackend } from './none.js';
|
|
6
|
+
/** Outcome of resolving a policy's `backend:` (incl. `auto`) against host reality. */
|
|
7
|
+
export interface Selection {
|
|
8
|
+
backend: IsolationBackend;
|
|
9
|
+
/** True when the requested backend was unavailable and we fell back to none. */
|
|
10
|
+
degraded: boolean;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
|
|
15
|
+
* rootless — OQ-5) and the `on_unavailable` degradation policy.
|
|
16
|
+
*
|
|
17
|
+
* - `none` → the identity backend.
|
|
18
|
+
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
19
|
+
* - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
|
|
20
|
+
* - `auto` → bwrap if available, else degrade/refuse.
|
|
21
|
+
*
|
|
22
|
+
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
23
|
+
*/
|
|
24
|
+
export declare function selectIsolationBackend(policy: ResolvedIsolation, exec?: Exec): Promise<Selection>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { realExec } from '../exec.js';
|
|
2
|
+
import { makeBubblewrapBackend } from './bubblewrap.js';
|
|
3
|
+
import { makeNoneBackend } from './none.js';
|
|
4
|
+
export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
|
|
5
|
+
export { makeNoneBackend } from './none.js';
|
|
6
|
+
/**
|
|
7
|
+
* Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
|
|
8
|
+
* rootless — OQ-5) and the `on_unavailable` degradation policy.
|
|
9
|
+
*
|
|
10
|
+
* - `none` → the identity backend.
|
|
11
|
+
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
12
|
+
* - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
|
|
13
|
+
* - `auto` → bwrap if available, else degrade/refuse.
|
|
14
|
+
*
|
|
15
|
+
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
16
|
+
*/
|
|
17
|
+
export async function selectIsolationBackend(policy, exec = realExec) {
|
|
18
|
+
if (policy.backend === 'none')
|
|
19
|
+
return { backend: makeNoneBackend(), degraded: false, detail: 'backend: none' };
|
|
20
|
+
const candidates = [];
|
|
21
|
+
if (policy.backend === 'auto' || policy.backend === 'bubblewrap')
|
|
22
|
+
candidates.push(makeBubblewrapBackend(exec));
|
|
23
|
+
// podman: Phase 6 — no candidate yet, so it falls through to on_unavailable.
|
|
24
|
+
let lastDetail = policy.backend === 'podman'
|
|
25
|
+
? 'podman backend not implemented yet (Phase 6)'
|
|
26
|
+
: 'no isolation backend available';
|
|
27
|
+
for (const b of candidates) {
|
|
28
|
+
const a = await b.available();
|
|
29
|
+
if (a.ok)
|
|
30
|
+
return { backend: b, degraded: false, detail: a.detail };
|
|
31
|
+
lastDetail = `${b.id} unavailable: ${a.detail}`;
|
|
32
|
+
}
|
|
33
|
+
if (policy.onUnavailable === 'strict')
|
|
34
|
+
throw new Error(`isolation strict mode: refusing to launch un-isolated — ${lastDetail}`);
|
|
35
|
+
return { backend: makeNoneBackend(), degraded: true, detail: lastDetail };
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IsolationResources } from './types.js';
|
|
2
|
+
export interface ResourceArgs {
|
|
3
|
+
argv: string[];
|
|
4
|
+
warnings: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
8
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): because tmux
|
|
9
|
+
* panes are children of the shared tmux server rather than the per-role unit, the
|
|
10
|
+
* only reliable per-agent limit is a transient scope at the pane itself.
|
|
11
|
+
*
|
|
12
|
+
* mem/pids are always enforced (their controllers are delegated to `--user` by
|
|
13
|
+
* default). cpu degrades to a warning when the cpu controller is not delegated.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resourceArgs(res: IsolationResources, cpuDelegated: boolean): ResourceArgs;
|
|
16
|
+
/** Whether the cpu cgroup-v2 controller is delegated to this user manager. */
|
|
17
|
+
export declare function cpuControllerDelegated(read?: (p: string) => string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
4
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): because tmux
|
|
5
|
+
* panes are children of the shared tmux server rather than the per-role unit, the
|
|
6
|
+
* only reliable per-agent limit is a transient scope at the pane itself.
|
|
7
|
+
*
|
|
8
|
+
* mem/pids are always enforced (their controllers are delegated to `--user` by
|
|
9
|
+
* default). cpu degrades to a warning when the cpu controller is not delegated.
|
|
10
|
+
*/
|
|
11
|
+
export function resourceArgs(res, cpuDelegated) {
|
|
12
|
+
const props = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
// MemorySwapMax=0 makes MemoryMax a hard OOM bound: without it, on a host with
|
|
15
|
+
// swap the overflow spills to swap instead of being killed, so the cap is soft
|
|
16
|
+
// and a rogue agent could exhaust host swap.
|
|
17
|
+
if (res.mem)
|
|
18
|
+
props.push(`MemoryMax=${res.mem}`, `MemorySwapMax=0`);
|
|
19
|
+
if (res.pids !== undefined)
|
|
20
|
+
props.push(`TasksMax=${res.pids}`);
|
|
21
|
+
if (res.cpu) {
|
|
22
|
+
const pct = Math.round(parseFloat(res.cpu) * 100);
|
|
23
|
+
if (!Number.isFinite(pct)) {
|
|
24
|
+
warnings.push(`ignoring unparseable cpu value '${res.cpu}'`);
|
|
25
|
+
}
|
|
26
|
+
else if (!cpuDelegated) {
|
|
27
|
+
warnings.push(`cpu cap '${res.cpu}' cores requested but the cpu cgroup controller is not delegated; ` +
|
|
28
|
+
`enforcing mem/pids only (see doctor: one-time Delegate=cpu). CPUQuota skipped.`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
props.push(`CPUQuota=${pct}%`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (props.length === 0)
|
|
35
|
+
return { argv: [], warnings };
|
|
36
|
+
const argv = ['systemd-run', '--user', '--scope'];
|
|
37
|
+
for (const p of props)
|
|
38
|
+
argv.push('-p', p);
|
|
39
|
+
argv.push('--');
|
|
40
|
+
return { argv, warnings };
|
|
41
|
+
}
|
|
42
|
+
/** Whether the cpu cgroup-v2 controller is delegated to this user manager. */
|
|
43
|
+
export function cpuControllerDelegated(read = p => readFileSync(p, 'utf8')) {
|
|
44
|
+
// The user manager's own cgroup lists the controllers delegated to it.
|
|
45
|
+
const uid = process.getuid?.() ?? 0;
|
|
46
|
+
const candidates = [
|
|
47
|
+
`/sys/fs/cgroup/user.slice/user-${uid}.slice/cgroup.controllers`,
|
|
48
|
+
`/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
|
|
49
|
+
];
|
|
50
|
+
for (const p of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
if (read(p).split(/\s+/).includes('cpu'))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch { /* try next */ }
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Isolation backend selector. `auto` probes bubblewrap then podman; `none` disables wrapping. */
|
|
2
|
+
export type IsolationBackendId = 'auto' | 'bubblewrap' | 'podman' | 'none';
|
|
3
|
+
/** What to do when the requested backend is unavailable. */
|
|
4
|
+
export type OnUnavailable = 'warn' | 'strict';
|
|
5
|
+
/** Network posture inside the sandbox. */
|
|
6
|
+
export type NetworkMode = 'broker' | 'deny' | 'allow' | 'allowlist';
|
|
7
|
+
export declare const BACKENDS: IsolationBackendId[];
|
|
8
|
+
export declare const ON_UNAVAILABLE: OnUnavailable[];
|
|
9
|
+
export declare const NETWORK_MODES: NetworkMode[];
|
|
10
|
+
export interface IsolationFs {
|
|
11
|
+
/** Extra read-only bind mounts (host paths). */
|
|
12
|
+
read?: string[];
|
|
13
|
+
/** Extra read-write bind mounts (host paths). */
|
|
14
|
+
write?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface IsolationResources {
|
|
17
|
+
/** Cores, e.g. "1.5" → CPUQuota=150%. */
|
|
18
|
+
cpu?: string;
|
|
19
|
+
/** Memory, e.g. "2G" → MemoryMax=2G. */
|
|
20
|
+
mem?: string;
|
|
21
|
+
/** Max processes → TasksMax. */
|
|
22
|
+
pids?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Raw `isolation:` block as it appears in fleet.yaml (all fields optional). */
|
|
25
|
+
export interface IsolationConfig {
|
|
26
|
+
backend?: IsolationBackendId;
|
|
27
|
+
on_unavailable?: OnUnavailable;
|
|
28
|
+
fs?: IsolationFs;
|
|
29
|
+
network?: NetworkMode;
|
|
30
|
+
allow_hosts?: string[];
|
|
31
|
+
resources?: IsolationResources;
|
|
32
|
+
secrets?: string[];
|
|
33
|
+
}
|
|
34
|
+
/** A single bind mount in the resolved sandbox. */
|
|
35
|
+
export interface Mount {
|
|
36
|
+
src: string;
|
|
37
|
+
dst: string;
|
|
38
|
+
mode: 'ro' | 'rw';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runtime facts the pure resolver needs to compute the durable mount set (§5.2):
|
|
42
|
+
* the agent's state dir, its working dir, the fleet user's home, and (if the ours
|
|
43
|
+
* broker exposes one) a unix-socket endpoint to bind in.
|
|
44
|
+
*/
|
|
45
|
+
export interface WrapContext {
|
|
46
|
+
stateDir: string;
|
|
47
|
+
runCwd: string;
|
|
48
|
+
home: string;
|
|
49
|
+
brokerEndpoint?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The validated, defaults-filled isolation policy consumed by a backend's wrap().
|
|
53
|
+
* Pure output of resolveIsolation — no I/O, no backend probing.
|
|
54
|
+
*/
|
|
55
|
+
export interface ResolvedIsolation {
|
|
56
|
+
backend: IsolationBackendId;
|
|
57
|
+
onUnavailable: OnUnavailable;
|
|
58
|
+
network: NetworkMode;
|
|
59
|
+
allowHosts: string[];
|
|
60
|
+
resources: IsolationResources;
|
|
61
|
+
/** rw + ro bind mounts: durable set, fs.* extras, secrets. */
|
|
62
|
+
mounts: Mount[];
|
|
63
|
+
/** read-only system dirs exposed under the allowlist model (/usr, /bin, …). */
|
|
64
|
+
system: string[];
|
|
65
|
+
/** ephemeral scratch tmpfs mounts (/tmp, ~/.cache). */
|
|
66
|
+
tmpfs: string[];
|
|
67
|
+
/** sensitive host paths guaranteed absent from the sandbox (observability). */
|
|
68
|
+
blocklist: string[];
|
|
69
|
+
}
|
|
70
|
+
/** A pluggable isolation backend (bubblewrap, podman, none). */
|
|
71
|
+
export interface IsolationBackend {
|
|
72
|
+
id: 'bubblewrap' | 'podman' | 'none';
|
|
73
|
+
/** Probe host support; feeds `doctor` and `auto` selection. */
|
|
74
|
+
available(): Promise<{
|
|
75
|
+
ok: boolean;
|
|
76
|
+
detail: string;
|
|
77
|
+
}>;
|
|
78
|
+
/** Wrap the agent argv into a sandbox-launcher argv given the resolved policy. */
|
|
79
|
+
wrap(argv: string[], policy: ResolvedIsolation, ctx: WrapContext): string[];
|
|
80
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -23,7 +23,8 @@ export function applyRole(role, opts = {}) {
|
|
|
23
23
|
writeFileSync(join(dir, 'WORKLOG.md'), '');
|
|
24
24
|
const briefingBody = role.briefing_file ? readFileSync(role.briefing_file, 'utf8') : undefined;
|
|
25
25
|
writeFileSync(join(dir, 'briefing.md'), generateBriefing(role, adapter.vocabulary, {
|
|
26
|
-
stateDir: dir, worklogPath: join(dir, 'WORKLOG.md'),
|
|
26
|
+
stateDir: dir, worklogPath: join(dir, 'WORKLOG.md'),
|
|
27
|
+
routinesPath: join(dir, 'ROUTINES.md'), briefingBody,
|
|
27
28
|
}));
|
|
28
29
|
if (opts.fresh)
|
|
29
30
|
for (const f of ['.booted', '.session-id', '.exit-status'])
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { type ResolvedRole } from './config.js';
|
|
2
2
|
import type { Launch } from './harness/types.js';
|
|
3
3
|
import { Tmux } from './tmux.js';
|
|
4
|
+
import { type Exec } from './exec.js';
|
|
4
5
|
export interface RunnerDeps {
|
|
5
6
|
tmux: Tmux;
|
|
7
|
+
exec: Exec;
|
|
8
|
+
cpuDelegated(): boolean;
|
|
6
9
|
isAlive(pid: number): boolean;
|
|
7
10
|
sleep(ms: number): Promise<void>;
|
|
8
11
|
now(): number;
|
|
9
12
|
log(line: string): void;
|
|
10
13
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
16
|
+
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
17
|
+
* the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
|
|
18
|
+
* `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
|
|
19
|
+
* still sees the real exit code.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
|
|
13
22
|
/** Read a temp role's config snapshot written by spawnTemp. */
|
|
14
23
|
export declare function loadTempRole(name: string): ResolvedRole;
|
|
15
24
|
/** One supervised session lifecycle. The supervisor re-invokes us after we return. */
|
package/dist/runner.js
CHANGED
|
@@ -2,13 +2,18 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
|
-
import { agentDir } from './paths.js';
|
|
5
|
+
import { agentDir, home } from './paths.js';
|
|
6
6
|
import { loadConfig, findRole } from './config.js';
|
|
7
7
|
import { getAdapter } from './harness/registry.js';
|
|
8
8
|
import { Tmux } from './tmux.js';
|
|
9
|
-
import { shq } from './exec.js';
|
|
9
|
+
import { realExec, shq } from './exec.js';
|
|
10
|
+
import { resolveIsolation } from './isolation/policy.js';
|
|
11
|
+
import { selectIsolationBackend } from './isolation/registry.js';
|
|
12
|
+
import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
|
|
10
13
|
const defaultDeps = () => ({
|
|
11
14
|
tmux: new Tmux(),
|
|
15
|
+
exec: realExec,
|
|
16
|
+
cpuDelegated: () => cpuControllerDelegated(),
|
|
12
17
|
isAlive: pid => { try {
|
|
13
18
|
process.kill(pid, 0);
|
|
14
19
|
return true;
|
|
@@ -20,11 +25,17 @@ const defaultDeps = () => ({
|
|
|
20
25
|
now: () => Date.now(),
|
|
21
26
|
log: line => process.stderr.write(line + '\n'),
|
|
22
27
|
});
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
30
|
+
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
31
|
+
* the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
|
|
32
|
+
* `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
|
|
33
|
+
* still sees the real exit code.
|
|
34
|
+
*/
|
|
35
|
+
export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
|
|
25
36
|
const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
|
|
26
37
|
const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
|
|
27
|
-
const cmd =
|
|
38
|
+
const cmd = paneArgv.map(shq).join(' ');
|
|
28
39
|
return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
|
|
29
40
|
}
|
|
30
41
|
/** Read a temp role's config snapshot written by spawnTemp. */
|
|
@@ -57,9 +68,29 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
57
68
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
58
69
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
59
70
|
const launch = adapter.buildLaunch(role, mode, { sessionId }, prep);
|
|
71
|
+
// Isolation is additive: only roles that declare `isolation:` are wrapped. The
|
|
72
|
+
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
73
|
+
let paneArgv = launch.argv;
|
|
74
|
+
if (role.isolation) {
|
|
75
|
+
const ctx = { stateDir: dir, runCwd, home: home() };
|
|
76
|
+
const policy = resolveIsolation(role.isolation, ctx);
|
|
77
|
+
const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
|
|
78
|
+
if (sel.degraded)
|
|
79
|
+
deps.log(`[${name}] WARNING isolation requested but unavailable -> running UN-ISOLATED: ${sel.detail}`);
|
|
80
|
+
else
|
|
81
|
+
deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
|
|
82
|
+
paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
|
|
83
|
+
// Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
|
|
84
|
+
// (§5.4). Applies even when the sandbox degraded to none.
|
|
85
|
+
const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
|
|
86
|
+
for (const w of warnings)
|
|
87
|
+
deps.log(`[${name}] WARNING ${w}`);
|
|
88
|
+
if (rprefix.length)
|
|
89
|
+
paneArgv = [...rprefix, ...paneArgv];
|
|
90
|
+
}
|
|
60
91
|
rmSync(exitFile, { force: true });
|
|
61
92
|
await deps.tmux.kill(name);
|
|
62
|
-
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile));
|
|
93
|
+
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
|
|
63
94
|
let pid = null;
|
|
64
95
|
for (let i = 0; i < 40 && pid === null; i++) {
|
|
65
96
|
pid = await deps.tmux.panePid(name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|