@ours.network/fleet 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +148 -30
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
@@ -1,5 +1,57 @@
1
- import { join, dirname } from 'node:path';
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. Pure no I/O, no probing.
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
- * 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).
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.harness === 'codex') {
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): 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.
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): 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.
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
- /** sensitive host paths guaranteed absent from the sandbox (observability). */
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,7 +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
+ /**
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
+ */
47
+ delivery?: {
48
+ submit(text: string): Promise<{
49
+ succeeded: boolean;
50
+ outcome: string;
51
+ detail?: string;
52
+ }>;
53
+ };
41
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';
42
62
  /** Best-effort daemon config (issue #17): the fields the MCP client reads. */
43
63
  interface DaemonConfig {
44
64
  apiToken?: string;
@@ -112,6 +132,8 @@ export declare function looksApiError(pane: string): boolean;
112
132
  export declare function looksRunning(pane: string): boolean;
113
133
  export interface MonitorOpts {
114
134
  name: string;
135
+ /** Ours identity whose notification stream is authoritative (may differ from role name). */
136
+ identity?: string;
115
137
  agentDir: string;
116
138
  cfg: MonitorConfig;
117
139
  deps: MonitorDeps;
@@ -124,20 +146,26 @@ export interface MonitorHandle {
124
146
  }
125
147
  export declare class Monitor {
126
148
  private readonly name;
149
+ private readonly identity;
127
150
  private readonly cfg;
128
151
  private readonly deps;
129
152
  private readonly ep;
130
153
  private readonly statusPath;
131
154
  private readonly cursorPath;
155
+ private readonly statePath;
132
156
  private cursor;
157
+ private deliveredCursor;
158
+ private pendingState;
133
159
  private fatal;
134
160
  private stopped;
135
161
  private bootDeadline;
136
162
  private currentAbort;
137
163
  private apiErrorStreak;
138
164
  private readonly turnFailThreshold;
165
+ /** Active degradations, keyed by cause. Empty means armed. */
166
+ private readonly causes;
139
167
  constructor(o: MonitorOpts);
140
- /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
168
+ /** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
141
169
  prime(): Promise<void>;
142
170
  /** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
143
171
  run(pid: number): Promise<void>;
@@ -175,7 +203,21 @@ export declare class Monitor {
175
203
  private advance;
176
204
  private persistCursor;
177
205
  private readPersistedCursor;
178
- private setStatus;
206
+ /** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
207
+ private persistState;
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;
179
221
  }
180
222
  export declare function createMonitor(o: MonitorOpts): Monitor;
181
223
  export {};