@ours.network/fleet 0.4.0 → 0.5.1
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/LICENSE +1 -1
- package/README.md +69 -5
- package/dist/cli.js +10 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/doctor.js +56 -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/runner.d.ts +11 -2
- package/dist/runner.js +42 -6
- package/package.json +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ours-fleet
|
|
2
2
|
|
|
3
|
-
**Run a fleet of persistent, identity-bound AI agents — across different agent
|
|
3
|
+
**Run a fleet of persistent, securely isolated, identity-bound AI agents — across different agent
|
|
4
4
|
harnesses — from one declarative file.**
|
|
5
5
|
|
|
6
6
|
## What is this?
|
|
@@ -178,12 +178,51 @@ roles:
|
|
|
178
178
|
# mem_palace: false # claude-code: disable memory plugin
|
|
179
179
|
# permission_mode: dontAsk # claude-code: launch permission mode —
|
|
180
180
|
# one of default | acceptEdits | plan | dontAsk | bypassPermissions
|
|
181
|
+
isolation: # OS-level sandbox (additive; omit = today's behavior)
|
|
182
|
+
backend: auto # auto | bubblewrap | podman | none (default auto)
|
|
183
|
+
on_unavailable: warn # warn (un-isolated + marker) | strict (refuse) (default warn)
|
|
184
|
+
network: broker # broker | deny | allow | allowlist (default broker)
|
|
185
|
+
fs: { read: [/opt/toolchains], write: [] } # extra binds (state dir + cwd always included)
|
|
186
|
+
resources: { mem: 2G, cpu: "1.5", pids: 512 }
|
|
187
|
+
secrets: ["/host/tok:/run/secrets/tok"] # host:container, mounted read-only
|
|
181
188
|
```
|
|
182
189
|
|
|
183
190
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
|
184
191
|
error naming both files. Identities and roles are decoupled — removing a role
|
|
185
192
|
never deletes an identity.
|
|
186
193
|
|
|
194
|
+
## Agent isolation
|
|
195
|
+
|
|
196
|
+
Each role can be sandboxed at the environment level via an `isolation:` block —
|
|
197
|
+
**fully additive: a role with no block behaves exactly as before.** The agent's
|
|
198
|
+
tmux-pane process is wrapped in [bubblewrap](https://github.com/containers/bubblewrap)
|
|
199
|
+
(rootless, no setuid), resource-limited by `systemd-run --user --scope`.
|
|
200
|
+
|
|
201
|
+
An empty `isolation: {}` gives a sensible default posture: filesystem-confined to
|
|
202
|
+
the state dir + `cwd`, the ours key store / other agents' state / `~/.ssh` / `~/.aws`
|
|
203
|
+
all invisible, ours messaging still works, no hard resource caps.
|
|
204
|
+
|
|
205
|
+
- **`backend`** — `auto` (bubblewrap if usable, else degrade per `on_unavailable`),
|
|
206
|
+
or force `bubblewrap` / `none`. (`podman` is planned.)
|
|
207
|
+
- **`on_unavailable`** — `warn` (default, fail-open: run un-isolated, log, and drop a
|
|
208
|
+
`.isolation-degraded` marker in the state dir) or `strict` (fail closed: refuse to launch).
|
|
209
|
+
- **`network`** — `broker` (default; ours messaging works), `deny` (no network),
|
|
210
|
+
`allow` (unrestricted), `allowlist` (planned). *Current status:* `deny` fully
|
|
211
|
+
unshares the network; `broker` keeps host networking so the loopback ours daemon
|
|
212
|
+
stays reachable — full broker egress-hardening is a follow-up.
|
|
213
|
+
- **`fs.read` / `fs.write`** — extra read-only / read-write binds on top of the durable set.
|
|
214
|
+
- **`resources`** — `mem` (→ `MemoryMax` + `MemorySwapMax=0`, a hard OOM bound),
|
|
215
|
+
`cpu` cores (→ `CPUQuota`), `pids` (→ `TasksMax`). CPU degrades to a warning if the
|
|
216
|
+
cpu cgroup controller isn't delegated (mem/pids still enforced).
|
|
217
|
+
- **`secrets`** — `host:container` pairs, mounted read-only; the only way host files
|
|
218
|
+
enter the sandbox.
|
|
219
|
+
|
|
220
|
+
`ours-fleet doctor` reports bubblewrap availability, cgroup delegation, and each
|
|
221
|
+
role's effective isolation; `ours-fleet config` prints a per-role isolation summary.
|
|
222
|
+
Isolation composes with `model`, `permission_mode`, and `ROUTINES.md`. See
|
|
223
|
+
[SECURITY.md](SECURITY.md#agent-isolation-sandboxing) for the threat model and the
|
|
224
|
+
rootless prerequisites.
|
|
225
|
+
|
|
187
226
|
## Development
|
|
188
227
|
|
|
189
228
|
```sh
|
|
@@ -202,8 +241,33 @@ software built by a small independent team, running the broker and relay service
|
|
|
202
241
|
at their own cost. If this is useful to you, please consider chipping in:
|
|
203
242
|
**→ https://github.com/adapt-toolkit/ours-donate**
|
|
204
243
|
|
|
205
|
-
##
|
|
244
|
+
## Licence, status & warranty
|
|
245
|
+
|
|
246
|
+
> **Alpha software.** ours-fleet is part of **ours.network**, which is early,
|
|
247
|
+
> experimental, **alpha-stage** software. It is under active development, its
|
|
248
|
+
> behaviour and interfaces may change without notice, and it is **not
|
|
249
|
+
> production-ready**.
|
|
250
|
+
|
|
251
|
+
> **No warranty / not security-audited.** ours.network has **not** been
|
|
252
|
+
> independently security-audited. It is provided **"as is", without warranty of
|
|
253
|
+
> any kind**, and you use it **at your own risk**. See [`LICENSE`](LICENSE) and
|
|
254
|
+
> [`SECURITY.md`](SECURITY.md).
|
|
255
|
+
|
|
256
|
+
**ours.network** is owned and licensed by **Adapt Framework Solutions Ltd**. It
|
|
257
|
+
is released under the **Functional Source License, Version 1.1
|
|
258
|
+
([FSL-1.1-Apache-2.0](LICENSE))** — **source-available, not open source** during
|
|
259
|
+
the FSL period. Each release **converts to Apache 2.0 two years after it is
|
|
260
|
+
published**.
|
|
261
|
+
|
|
262
|
+
The FSL permits any use **except a Competing Use** — broadly, offering a
|
|
263
|
+
commercial product or service that substitutes for, or provides substantially
|
|
264
|
+
the same functionality as, ours.network. Competing/commercial use requires a
|
|
265
|
+
separate **commercial licence** from Adapt Framework Solutions Ltd — see
|
|
266
|
+
[`COMMERCIAL-LICENCE.md`](COMMERCIAL-LICENCE.md) (contact:
|
|
267
|
+
**license@adaptframework.solutions**).
|
|
268
|
+
|
|
269
|
+
ours.network builds on Adapt Framework Solutions Ltd's own FSL-licensed core (the
|
|
270
|
+
`@adapt-toolkit` packages); **Adapt itself is not part of this release** and is
|
|
271
|
+
licensed separately.
|
|
206
272
|
|
|
207
|
-
|
|
208
|
-
Apache-2.0 two years after each release. Free for any use except offering a
|
|
209
|
-
competing product or service. Copyright 2026 ours.network contributors.
|
|
273
|
+
Copyright 2026 Adapt Framework Solutions Ltd.
|
package/dist/cli.js
CHANGED
|
@@ -56,6 +56,16 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
56
56
|
console.log(` mission: ${r.mission.split('\n')[0]}`);
|
|
57
57
|
if (r.oversee?.length)
|
|
58
58
|
console.log(` oversees: ${r.oversee.map(o => `${o.role}@${o.interval}`).join(', ')}`);
|
|
59
|
+
if (r.isolation) {
|
|
60
|
+
const iso = r.isolation;
|
|
61
|
+
const caps = [
|
|
62
|
+
iso.resources?.mem && `mem=${iso.resources.mem}`,
|
|
63
|
+
iso.resources?.cpu && `cpu=${iso.resources.cpu}`,
|
|
64
|
+
iso.resources?.pids !== undefined && `pids=${iso.resources.pids}`,
|
|
65
|
+
].filter(Boolean).join(',') || 'none';
|
|
66
|
+
console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
|
|
67
|
+
+ `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
|
|
68
|
+
}
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
catch (e) {
|
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
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import { userInfo } from 'node:os';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { realExec } from './exec.js';
|
|
3
4
|
import { loadConfig } from './config.js';
|
|
4
5
|
import { getAdapter } from './harness/registry.js';
|
|
6
|
+
import { agentDir, home } from './paths.js';
|
|
7
|
+
import { resolveIsolation } from './isolation/policy.js';
|
|
8
|
+
import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
|
|
9
|
+
/** Which cgroup-v2 controllers are delegated to this user manager (advisory). */
|
|
10
|
+
function cgroupDelegationDetail() {
|
|
11
|
+
try {
|
|
12
|
+
const uid = process.getuid?.() ?? 0;
|
|
13
|
+
const c = readFileSync(`/sys/fs/cgroup/user.slice/user-${uid}.slice/cgroup.controllers`, 'utf8').split(/\s+/);
|
|
14
|
+
const has = (n) => (c.includes(n) ? 'yes' : 'no');
|
|
15
|
+
return `memory=${has('memory')} pids=${has('pids')} cpu=${has('cpu')}` +
|
|
16
|
+
(c.includes('cpu') ? '' : ' — cpu caps degrade to a warning (one-time: Delegate=cpu)');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return 'unknown (not cgroup-v2 or no delegation info)';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
5
22
|
/** Host-level + per-harness prerequisite report with actionable messages. */
|
|
6
23
|
export async function doctor(opts = {}, exec = realExec, platform = process.platform) {
|
|
7
24
|
const checks = [];
|
|
@@ -37,9 +54,47 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
37
54
|
: `not enabled — run: ours-fleet init (or: sudo loginctl enable-linger ${user})`,
|
|
38
55
|
});
|
|
39
56
|
}
|
|
57
|
+
// Isolation reporting (AC-9). Backend availability is advisory — isolation is
|
|
58
|
+
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
59
|
+
// don't use it. Only a role that DECLARES isolation and cannot get it under
|
|
60
|
+
// `strict` is a hard failure.
|
|
61
|
+
const roles = loadConfigSafe(opts.configPath);
|
|
62
|
+
const bw = await makeBubblewrapBackend(exec).available();
|
|
63
|
+
checks.push({
|
|
64
|
+
name: 'isolation: bubblewrap', ok: true,
|
|
65
|
+
detail: bw.ok
|
|
66
|
+
? `available — ${bw.detail}`
|
|
67
|
+
: `not available: ${bw.detail} (only needed for roles declaring isolation:)`,
|
|
68
|
+
});
|
|
69
|
+
if (platform === 'linux')
|
|
70
|
+
checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
|
|
71
|
+
for (const r of roles.filter(r => r.isolation)) {
|
|
72
|
+
const stateDir = agentDir(r.name);
|
|
73
|
+
const policy = resolveIsolation(r.isolation, { stateDir, runCwd: r.cwd ?? stateDir, home: home() });
|
|
74
|
+
const caps = [
|
|
75
|
+
policy.resources.mem && `mem=${policy.resources.mem}`,
|
|
76
|
+
policy.resources.cpu && `cpu=${policy.resources.cpu}`,
|
|
77
|
+
policy.resources.pids !== undefined && `pids=${policy.resources.pids}`,
|
|
78
|
+
].filter(Boolean).join(',') || 'none';
|
|
79
|
+
const wantsBwrap = policy.backend === 'auto' || policy.backend === 'bubblewrap';
|
|
80
|
+
let ok = true, detail;
|
|
81
|
+
if (policy.backend === 'none')
|
|
82
|
+
detail = 'backend=none (explicitly un-sandboxed)';
|
|
83
|
+
else if (wantsBwrap && bw.ok)
|
|
84
|
+
detail = `backend=bubblewrap net=${policy.network} caps=${caps}`;
|
|
85
|
+
else if (wantsBwrap && policy.onUnavailable === 'strict') {
|
|
86
|
+
ok = false;
|
|
87
|
+
detail = 'WILL REFUSE to launch (strict): bubblewrap unavailable';
|
|
88
|
+
}
|
|
89
|
+
else if (wantsBwrap)
|
|
90
|
+
detail = `degraded->un-isolated (warn): bubblewrap unavailable; caps=${caps} still apply`;
|
|
91
|
+
else
|
|
92
|
+
detail = `backend=${policy.backend} (not yet implemented)`;
|
|
93
|
+
checks.push({ name: `isolation: ${r.name}`, ok, detail });
|
|
94
|
+
}
|
|
40
95
|
const harnesses = opts.harness
|
|
41
96
|
? [opts.harness]
|
|
42
|
-
: [...new Set(
|
|
97
|
+
: [...new Set(roles.map(r => r.harness))];
|
|
43
98
|
for (const h of harnesses) {
|
|
44
99
|
try {
|
|
45
100
|
const rep = await getAdapter(h).checkPrereqs();
|
|
@@ -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/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,34 @@ 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
|
+
const degradedMarker = join(dir, '.isolation-degraded');
|
|
79
|
+
if (sel.degraded) {
|
|
80
|
+
deps.log(`[${name}] WARNING isolation requested but unavailable -> running UN-ISOLATED: ${sel.detail}`);
|
|
81
|
+
writeFileSync(degradedMarker, `${new Date().toISOString()} ${sel.detail}\n`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
|
|
85
|
+
rmSync(degradedMarker, { force: true });
|
|
86
|
+
}
|
|
87
|
+
paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
|
|
88
|
+
// Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
|
|
89
|
+
// (§5.4). Applies even when the sandbox degraded to none.
|
|
90
|
+
const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
|
|
91
|
+
for (const w of warnings)
|
|
92
|
+
deps.log(`[${name}] WARNING ${w}`);
|
|
93
|
+
if (rprefix.length)
|
|
94
|
+
paneArgv = [...rprefix, ...paneArgv];
|
|
95
|
+
}
|
|
60
96
|
rmSync(exitFile, { force: true });
|
|
61
97
|
await deps.tmux.kill(name);
|
|
62
|
-
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile));
|
|
98
|
+
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
|
|
63
99
|
let pid = null;
|
|
64
100
|
for (let i = 0; i < 40 && pid === null; i++) {
|
|
65
101
|
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.1",
|
|
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",
|