@ours.network/fleet 0.9.5 → 0.9.8
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 +101 -0
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- 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 +30 -3
- package/dist/monitor.js +145 -30
- 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 +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- 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
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;
|
|
@@ -51,6 +72,16 @@ export interface BriefingVocab {
|
|
|
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 };
|
package/dist/isolation/policy.js
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
2
3
|
import { BACKENDS, ON_UNAVAILABLE, NETWORK_MODES, } from './types.js';
|
|
4
|
+
/** A mount that the forbidden-path policy refuses. Raised before any launch. */
|
|
5
|
+
export class IsolationPolicyError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'IsolationPolicyError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a path to its canonical form, following symlinks as far as the
|
|
13
|
+
* filesystem allows and normalising the rest. Without this, `~/link-to-ssh`
|
|
14
|
+
* and `/home/u/.ssh` are different strings for the same directory, and a
|
|
15
|
+
* string comparison against the forbidden list is trivially side-stepped.
|
|
16
|
+
*/
|
|
17
|
+
export function canonicalPath(p) {
|
|
18
|
+
const abs = resolve(p);
|
|
19
|
+
let head = abs;
|
|
20
|
+
let tail = '';
|
|
21
|
+
for (;;) {
|
|
22
|
+
try {
|
|
23
|
+
return tail ? join(realpathSync.native(head), tail) : realpathSync.native(head);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
const parent = dirname(head);
|
|
27
|
+
if (parent === head)
|
|
28
|
+
return abs; // nothing on this path exists yet
|
|
29
|
+
tail = tail ? join(basename(head), tail) : basename(head);
|
|
30
|
+
head = parent;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const within = (child, parent) => child === parent || child.startsWith(parent + sep);
|
|
35
|
+
/**
|
|
36
|
+
* How a canonical mount path collides with a canonical forbidden path.
|
|
37
|
+
*
|
|
38
|
+
* `parent` matters as much as the other two: binding `$HOME` does not name
|
|
39
|
+
* `~/.ssh`, but it exposes it just as completely.
|
|
40
|
+
*/
|
|
41
|
+
export function mountConflict(mount, forbidden) {
|
|
42
|
+
if (mount === forbidden)
|
|
43
|
+
return 'exact';
|
|
44
|
+
if (within(mount, forbidden))
|
|
45
|
+
return 'descendant';
|
|
46
|
+
if (within(forbidden, mount))
|
|
47
|
+
return 'parent';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const CONFLICT_WORDING = {
|
|
51
|
+
exact: 'is',
|
|
52
|
+
descendant: 'is inside',
|
|
53
|
+
parent: 'would expose',
|
|
54
|
+
};
|
|
3
55
|
/** Read-only system dirs exposed under the allowlist model. */
|
|
4
56
|
const SYSTEM_RO = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
|
|
5
57
|
/** Ephemeral scratch mounts. */
|
|
@@ -52,6 +104,12 @@ export function validateIsolationConfig(raw) {
|
|
|
52
104
|
}
|
|
53
105
|
return problems;
|
|
54
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Where a role's per-role harness runtime state lives (5.1). Under the agent's
|
|
109
|
+
* own state directory, so it is covered by the state dir's existing lifecycle
|
|
110
|
+
* and by the forbidden-path exception, and is never shared with a peer.
|
|
111
|
+
*/
|
|
112
|
+
export const harnessRuntimeDir = (stateDir, harnessId) => join(stateDir, 'harness', harnessId);
|
|
55
113
|
/** Parse a `host:container` secret pair; a bare path maps to itself. */
|
|
56
114
|
function parseSecret(pair) {
|
|
57
115
|
const i = pair.indexOf(':');
|
|
@@ -61,12 +119,17 @@ function parseSecret(pair) {
|
|
|
61
119
|
}
|
|
62
120
|
/**
|
|
63
121
|
* Resolve a raw (already validated) isolation block against runtime context into
|
|
64
|
-
* a defaults-filled, backend-agnostic policy
|
|
122
|
+
* a defaults-filled, backend-agnostic policy, and REFUSE any mount that would
|
|
123
|
+
* breach the forbidden-path list.
|
|
124
|
+
*
|
|
125
|
+
* The mount model is an allowlist: only the durable set (state dir, cwd, harness
|
|
126
|
+
* config, declared fs/secrets) plus read-only system dirs are exposed. The
|
|
127
|
+
* forbidden list — the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws
|
|
128
|
+
* — is now enforced on top of that, so a role cannot ask its way back in.
|
|
65
129
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* is simply never mounted, and thus absent inside the sandbox (§5.2).
|
|
130
|
+
* Not pure: canonicalising a path reads the filesystem, because symlink aliases
|
|
131
|
+
* are one of the ways a forbidden path gets requested. Throws
|
|
132
|
+
* `IsolationPolicyError`; callers surface it against the role.
|
|
70
133
|
*/
|
|
71
134
|
export function resolveIsolation(cfg, ctx) {
|
|
72
135
|
const { stateDir, runCwd, home } = ctx;
|
|
@@ -75,10 +138,26 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
75
138
|
mounts.push({ src: p, dst: p, mode: 'rw' }); };
|
|
76
139
|
const addRo = (p) => { if (!mounts.some(m => m.src === p))
|
|
77
140
|
mounts.push({ src: p, dst: p, mode: 'ro' }); };
|
|
141
|
+
/** Writable bind whose destination differs from its source (the per-role home). */
|
|
142
|
+
const addRw2 = (src, dst) => {
|
|
143
|
+
if (!mounts.some(m => m.src === src && m.dst === dst))
|
|
144
|
+
mounts.push({ src, dst, mode: 'rw' });
|
|
145
|
+
};
|
|
78
146
|
// Durable set: state dir + cwd, then only the active harness's config/auth roots.
|
|
79
147
|
addRw(stateDir);
|
|
80
148
|
addRw(runCwd);
|
|
81
|
-
if (ctx.
|
|
149
|
+
if (ctx.harnessHome && ctx.harnessRuntimeDir) {
|
|
150
|
+
// The harness home is backed by a PER-ROLE directory (5.1): the agent gets a
|
|
151
|
+
// writable home for its sessions, caches and history, and anything a future
|
|
152
|
+
// CLI version writes lands there too. The shared credentials, global
|
|
153
|
+
// instructions and configuration are then layered back read-only, so they
|
|
154
|
+
// are readable and cannot be rewritten — for this role or for its peers.
|
|
155
|
+
// Order matters: the writable home must precede the read-only overlays.
|
|
156
|
+
addRw2(ctx.harnessRuntimeDir, ctx.harnessHome);
|
|
157
|
+
for (const p of ctx.harnessSharedPaths ?? [])
|
|
158
|
+
addRo(p);
|
|
159
|
+
}
|
|
160
|
+
else if (ctx.harness === 'codex') {
|
|
82
161
|
addRw(join(home, '.codex'));
|
|
83
162
|
addRo(join(home, '.agents'));
|
|
84
163
|
}
|
|
@@ -105,6 +184,34 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
105
184
|
...SENSITIVE_HOME.map(p => join(home, p)),
|
|
106
185
|
agentsRoot, // sibling agents' state dirs (this agent's own is explicitly mounted)
|
|
107
186
|
];
|
|
187
|
+
// ENFORCE the list, before anything builds a backend argv. Until now it was
|
|
188
|
+
// observational: the allowlist model kept these paths out by default, but a
|
|
189
|
+
// role that asked for one in `fs.write`, `secrets`, or a Codex `add_dirs` got
|
|
190
|
+
// it mounted anyway, and the "blocklist" recorded a guarantee it never made.
|
|
191
|
+
//
|
|
192
|
+
// The role's OWN state dir is the one legitimate descendant of the agents
|
|
193
|
+
// root, so it is excepted by exact canonical identity — which does not
|
|
194
|
+
// exempt its parent, and does not exempt a sibling.
|
|
195
|
+
const forbidden = blocklist.map(canonicalPath);
|
|
196
|
+
const ownStateDir = canonicalPath(stateDir);
|
|
197
|
+
for (const m of mounts) {
|
|
198
|
+
for (const [role, path] of [['source', m.src], ['destination', m.dst]]) {
|
|
199
|
+
const canon = canonicalPath(path);
|
|
200
|
+
// The role's own state dir and anything inside it (its per-role harness
|
|
201
|
+
// runtime home, 5.1) is the one legitimate descendant of the agents root.
|
|
202
|
+
// This does not exempt the root above it, nor a sibling beside it.
|
|
203
|
+
if (within(canon, ownStateDir))
|
|
204
|
+
continue;
|
|
205
|
+
for (let i = 0; i < forbidden.length; i++) {
|
|
206
|
+
const kind = mountConflict(canon, forbidden[i]);
|
|
207
|
+
if (!kind)
|
|
208
|
+
continue;
|
|
209
|
+
const alias = canon === resolve(path) ? '' : ` (resolves to '${canon}')`;
|
|
210
|
+
throw new IsolationPolicyError(`isolation: refusing to mount ${role} '${path}'${alias} — it ` +
|
|
211
|
+
`${CONFLICT_WORDING[kind]} the forbidden path '${blocklist[i]}'`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
108
215
|
return {
|
|
109
216
|
backend: cfg.backend ?? 'auto',
|
|
110
217
|
onUnavailable: cfg.on_unavailable ?? 'warn',
|
|
@@ -5,9 +5,12 @@ export interface ResourceArgs {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
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):
|
|
9
|
-
*
|
|
10
|
-
* only reliable per-agent limit is a transient scope at the pane itself.
|
|
8
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): a tmux pane is
|
|
9
|
+
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
10
|
+
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
11
|
+
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
12
|
+
* role's `stop` off every other role's pane — the limit still belongs on the
|
|
13
|
+
* pane.)
|
|
11
14
|
*
|
|
12
15
|
* mem/pids are always enforced (their controllers are delegated to `--user` by
|
|
13
16
|
* default). cpu degrades to a warning when the cpu controller is not delegated.
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
/**
|
|
3
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):
|
|
5
|
-
*
|
|
6
|
-
* only reliable per-agent limit is a transient scope at the pane itself.
|
|
4
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): a tmux pane is
|
|
5
|
+
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
6
|
+
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
7
|
+
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
8
|
+
* role's `stop` off every other role's pane — the limit still belongs on the
|
|
9
|
+
* pane.)
|
|
7
10
|
*
|
|
8
11
|
* mem/pids are always enforced (their controllers are delegated to `--user` by
|
|
9
12
|
* default). cpu degrades to a warning when the cpu controller is not delegated.
|
|
@@ -50,6 +50,19 @@ export interface WrapContext {
|
|
|
50
50
|
harness?: string;
|
|
51
51
|
/** Harness-declared writable roots (for example Codex --add-dir). */
|
|
52
52
|
additionalWriteDirs?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* The harness's home directory on the host (`~/.claude`, `~/.codex`). Mounted
|
|
55
|
+
* from `harnessRuntimeDir` so the agent's own runtime state is per-role (5.1).
|
|
56
|
+
*/
|
|
57
|
+
harnessHome?: string;
|
|
58
|
+
/** Per-role writable directory backing `harnessHome` inside the sandbox. */
|
|
59
|
+
harnessRuntimeDir?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Shared credentials, global instructions and configuration. Mounted READ-ONLY
|
|
62
|
+
* on top of the per-role home, so an agent can read them and cannot rewrite
|
|
63
|
+
* them for itself or for its peers.
|
|
64
|
+
*/
|
|
65
|
+
harnessSharedPaths?: string[];
|
|
53
66
|
brokerEndpoint?: string;
|
|
54
67
|
}
|
|
55
68
|
/**
|
|
@@ -68,7 +81,12 @@ export interface ResolvedIsolation {
|
|
|
68
81
|
system: string[];
|
|
69
82
|
/** ephemeral scratch tmpfs mounts (/tmp, ~/.cache). */
|
|
70
83
|
tmpfs: string[];
|
|
71
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Sensitive host paths that are ENFORCED absent from the sandbox: any mount
|
|
86
|
+
* that is, sits inside, or would expose one of these is refused by
|
|
87
|
+
* `resolveIsolation` before a backend argv is built. Retained on the resolved
|
|
88
|
+
* policy for diagnostics — doctor and `config` report what is being enforced.
|
|
89
|
+
*/
|
|
72
90
|
blocklist: string[];
|
|
73
91
|
}
|
|
74
92
|
/** A pluggable isolation backend (bubblewrap, podman, none). */
|
package/dist/monitor.d.ts
CHANGED
|
@@ -38,14 +38,27 @@ export interface MonitorDeps {
|
|
|
38
38
|
set(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
|
|
39
39
|
clear(t: ReturnType<typeof setTimeout>): void;
|
|
40
40
|
};
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Structured prompt delivery used by ACP sessions. Tmux remains the fallback.
|
|
43
|
+
* `succeeded` is the turn's TERMINAL result, not merely that the session took
|
|
44
|
+
* the prompt: a refused or cancelled wake was seen and not acted on, and must
|
|
45
|
+
* not commit the cursor.
|
|
46
|
+
*/
|
|
42
47
|
delivery?: {
|
|
43
48
|
submit(text: string): Promise<{
|
|
44
|
-
|
|
49
|
+
succeeded: boolean;
|
|
50
|
+
outcome: string;
|
|
45
51
|
detail?: string;
|
|
46
52
|
}>;
|
|
47
53
|
};
|
|
48
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Why the monitor is not healthy. Each cause clears on its OWN recovery signal
|
|
57
|
+
* and nothing else — a successful poll proves the stream works, and proves
|
|
58
|
+
* nothing whatsoever about whether wakes are being delivered or whether the
|
|
59
|
+
* turns they trigger keep dying.
|
|
60
|
+
*/
|
|
61
|
+
export type StatusCause = 'connectivity' | 'delivery' | 'modal' | 'offline' | 'turns-failing' | 'auth';
|
|
49
62
|
/** Best-effort daemon config (issue #17): the fields the MCP client reads. */
|
|
50
63
|
interface DaemonConfig {
|
|
51
64
|
apiToken?: string;
|
|
@@ -149,6 +162,8 @@ export declare class Monitor {
|
|
|
149
162
|
private currentAbort;
|
|
150
163
|
private apiErrorStreak;
|
|
151
164
|
private readonly turnFailThreshold;
|
|
165
|
+
/** Active degradations, keyed by cause. Empty means armed. */
|
|
166
|
+
private readonly causes;
|
|
152
167
|
constructor(o: MonitorOpts);
|
|
153
168
|
/** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
|
|
154
169
|
prime(): Promise<void>;
|
|
@@ -190,7 +205,19 @@ export declare class Monitor {
|
|
|
190
205
|
private readPersistedCursor;
|
|
191
206
|
/** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
|
|
192
207
|
private persistState;
|
|
193
|
-
|
|
208
|
+
/** Record a degradation under its own cause and republish the status. */
|
|
209
|
+
private degrade;
|
|
210
|
+
/**
|
|
211
|
+
* Clear exactly the causes this recovery signal speaks to. Anything else
|
|
212
|
+
* stays: one successful poll must never be able to erase `turns failing`.
|
|
213
|
+
*/
|
|
214
|
+
private recover;
|
|
215
|
+
/**
|
|
216
|
+
* One line per active cause, each dated; `armed` when there are none. Every
|
|
217
|
+
* line carries an ISO timestamp so an operator can tell a live status from a
|
|
218
|
+
* stale one left behind by a monitor that stopped writing.
|
|
219
|
+
*/
|
|
220
|
+
private writeStatus;
|
|
194
221
|
}
|
|
195
222
|
export declare function createMonitor(o: MonitorOpts): Monitor;
|
|
196
223
|
export {};
|