@ours.network/fleet 0.19.0-nightly.8 → 1.0.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.
Files changed (116) hide show
  1. package/README.md +96 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +145 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +733 -221
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +182 -0
  51. package/dist/rooms-tasks/config.js +8 -5
  52. package/dist/rooms-tasks/cowork-adapter.d.ts +22 -0
  53. package/dist/rooms-tasks/cowork-adapter.js +116 -0
  54. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  55. package/dist/rooms-tasks/external-worker.js +40 -0
  56. package/dist/rooms-tasks/index.d.ts +2 -0
  57. package/dist/rooms-tasks/index.js +2 -0
  58. package/dist/rooms-tasks/markdown.d.ts +49 -0
  59. package/dist/rooms-tasks/markdown.js +206 -0
  60. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  61. package/dist/rooms-tasks/member-startup.js +151 -0
  62. package/dist/rooms-tasks/provision.d.ts +8 -0
  63. package/dist/rooms-tasks/provision.js +363 -97
  64. package/dist/rooms-tasks/room-state.d.ts +13 -1
  65. package/dist/rooms-tasks/room-state.js +149 -1
  66. package/dist/rooms-tasks/task-state.d.ts +13 -2
  67. package/dist/rooms-tasks/task-state.js +131 -3
  68. package/dist/rooms-tasks/terminal.d.ts +23 -0
  69. package/dist/rooms-tasks/terminal.js +56 -0
  70. package/dist/rooms-tasks/types.d.ts +109 -4
  71. package/dist/rooms-tasks/types.js +1 -1
  72. package/dist/runner.d.ts +48 -0
  73. package/dist/runner.js +252 -100
  74. package/dist/session/acp.d.ts +106 -2
  75. package/dist/session/acp.js +217 -14
  76. package/dist/session/activity.d.ts +31 -0
  77. package/dist/session/activity.js +48 -0
  78. package/dist/session/control.d.ts +1 -1
  79. package/dist/session/conversation-normalizer.d.ts +7 -1
  80. package/dist/session/conversation-normalizer.js +154 -11
  81. package/dist/session/conversation-store.js +1 -1
  82. package/dist/session/conversation-types.d.ts +25 -6
  83. package/dist/session/types.d.ts +35 -0
  84. package/dist/spawn.d.ts +4 -2
  85. package/dist/spawn.js +39 -26
  86. package/dist/supervisor/launchd.d.ts +2 -2
  87. package/dist/supervisor/launchd.js +4 -4
  88. package/dist/supervisor/systemd.js +4 -4
  89. package/dist/supervisor/types.d.ts +1 -1
  90. package/dist/temp-lifecycle.d.ts +14 -2
  91. package/dist/temp-lifecycle.js +90 -0
  92. package/dist/watchdog/alerts.d.ts +2 -2
  93. package/dist/watchdog/alerts.js +2 -2
  94. package/dist/watchdog/briefing.d.ts +5 -5
  95. package/dist/watchdog/briefing.js +13 -6
  96. package/dist/watchdog/config.js +1 -1
  97. package/dist/watchdog/query.d.ts +2 -2
  98. package/dist/watchdog/query.js +2 -2
  99. package/dist/watchdog/report.js +2 -2
  100. package/dist/watchdog/run.d.ts +2 -2
  101. package/dist/watchdog/run.js +6 -6
  102. package/dist/watchdog/scheduler.d.ts +7 -7
  103. package/dist/watchdog/scheduler.js +10 -10
  104. package/dist/watchdog/service.d.ts +4 -4
  105. package/dist/watchdog/service.js +5 -6
  106. package/dist/watchdog/store.d.ts +4 -4
  107. package/dist/watchdog/store.js +4 -4
  108. package/dist/web/runtime.js +1 -1
  109. package/dist/web/server.js +1 -1
  110. package/dist/web/topology-promote.js +1 -1
  111. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  112. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  113. package/dist/web-app/index.html +1 -1
  114. package/dist/worklog.d.ts +7 -1
  115. package/dist/worklog.js +191 -39
  116. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
- import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
8
+ import { resolveBundledAcpAgent, } from './acp-agent.js';
9
9
  const OPTION_KEYS = [
10
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
11
  'monitor',
@@ -51,9 +51,7 @@ function sandboxMode(role) {
51
51
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
52
  return s;
53
53
  }
54
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
- function acpAgentMode(role) {
56
- const sandbox = sandboxMode(role);
54
+ function modeForSandbox(sandbox) {
57
55
  if (sandbox === 'read-only')
58
56
  return 'read-only';
59
57
  if (sandbox === 'workspace-write')
@@ -62,6 +60,26 @@ function acpAgentMode(role) {
62
60
  return 'agent-full-access';
63
61
  return undefined;
64
62
  }
63
+ /**
64
+ * Resolve the coupled Codex ACP mode.
65
+ *
66
+ * The portable approval contract owns the default mode selection: `allow`
67
+ * means the adapter's fully non-interactive yolo preset and `auto` means its
68
+ * ordinary agent preset. This intentionally means that Codex ACP cannot retain
69
+ * an independent neutral filesystem posture for those two modes. An explicit
70
+ * native sandbox remains authoritative and selects its corresponding preset.
71
+ */
72
+ function acpAgentMode(role) {
73
+ const explicitSandbox = role.harness_options?.sandbox;
74
+ if (explicitSandbox != null)
75
+ return modeForSandbox(sandboxMode(role));
76
+ if (role.permissions?.approval === 'allow')
77
+ return 'agent-full-access';
78
+ if (role.permissions?.approval === 'auto')
79
+ return 'agent';
80
+ const sandbox = sandboxMode(role);
81
+ return modeForSandbox(sandbox);
82
+ }
65
83
  function acpModePermissions(mode) {
66
84
  if (mode === 'read-only')
67
85
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -69,6 +87,10 @@ function acpModePermissions(mode) {
69
87
  return { approval: 'never', sandbox: 'danger-full-access' };
70
88
  return { approval: 'on-request', sandbox: 'workspace-write' };
71
89
  }
90
+ /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
91
+ function acpRuntimeSandbox(role) {
92
+ return acpModePermissions(acpAgentMode(role)).sandbox;
93
+ }
72
94
  function fleetModeForApproval(nativeMode) {
73
95
  if (nativeMode === 'never')
74
96
  return 'allow';
@@ -102,6 +124,18 @@ function launcherMode(role) {
102
124
  function bundledCodexAcp() {
103
125
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
126
  }
127
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
128
+ export function codexAcpLaunchForResolution(resolution) {
129
+ const permissionMetadataSource = resolution.bundled
130
+ && resolution.version === BUNDLED_CODEX_ACP_VERSION
131
+ && resolution.manifestPath !== undefined
132
+ ? 'codex-acp'
133
+ : undefined;
134
+ return {
135
+ argv: [...resolution.argv],
136
+ ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
137
+ };
138
+ }
105
139
  function canOverrideBundledAcpApproval() {
106
140
  const resolution = bundledCodexAcp();
107
141
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -147,7 +181,7 @@ function codexAcpEnvironment(role, dirs) {
147
181
  return {
148
182
  CODEX_PATH: command,
149
183
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
- [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
184
+ [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
151
185
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
186
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
187
  };
@@ -275,7 +309,7 @@ export function makeCodexAdapter(exec = realExec) {
275
309
  return errs;
276
310
  },
277
311
  async prepareSession(role, dirs) {
278
- // Per-role harness runtime home (5.1); harmless for un-isolated roles.
312
+ // Per-role harness runtime home; harmless for un-isolated roles.
279
313
  // Only a role that declares `isolation:` gets a sandbox, and only a
280
314
  // sandbox needs this directory to exist before entry.
281
315
  if (role.isolation)
@@ -285,7 +319,16 @@ export function makeCodexAdapter(exec = realExec) {
285
319
  if (requested === 'ours-codex' && !hasOursCodex)
286
320
  throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
287
321
  const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
288
- return { argv: [], env: codexAcpEnvironment(role, dirs), command };
322
+ return {
323
+ argv: [],
324
+ // OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
325
+ // claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
326
+ // role with an ours identity, not just claude-code: a seed that works on one
327
+ // harness and silently does nothing on the other is the same class of defect
328
+ // as a config key that only works on one session type.
329
+ env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
330
+ command,
331
+ };
289
332
  },
290
333
  buildLaunch(role, mode, _s, prep) {
291
334
  const stateDir = roleStateDir(role);
@@ -299,17 +342,30 @@ export function makeCodexAdapter(exec = realExec) {
299
342
  },
300
343
  buildAcpLaunch(role, prep) {
301
344
  const configured = role.session_options?.acp?.command;
345
+ // Resolve once: both argv and permission-metadata provenance must describe
346
+ // the same artifact. A bare PATH fallback is launchable for compatibility,
347
+ // but is never authenticated for protected-MCP auto-approval.
348
+ const resolved = configured == null
349
+ ? codexAcpLaunchForResolution(bundledCodexAcp())
350
+ : undefined;
302
351
  const argv = Array.isArray(configured)
303
352
  ? [...configured]
304
353
  : typeof configured === 'string'
305
354
  ? ['sh', '-c', configured]
306
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
355
+ : resolved.argv;
307
356
  const initialMode = acpAgentMode(role);
308
357
  return {
309
358
  argv,
310
359
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
360
+ ...(resolved?.permissionMetadataSource
361
+ ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
311
362
  };
312
363
  },
364
+ // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
365
+ // keeps resumed/loaded sessions and live status on the identical mode.
366
+ acpPermissionModeId(role) {
367
+ return acpAgentMode(role);
368
+ },
313
369
  isolationPaths(role, _dirs) {
314
370
  const codexHome = join(home(), '.codex');
315
371
  const profile = role.harness_options?.profile;
@@ -362,7 +418,9 @@ export function makeCodexAdapter(exec = realExec) {
362
418
  const mode = acpAgentMode(role) ?? 'agent';
363
419
  const configured = role.session_options?.acp?.command;
364
420
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
- const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
421
+ const actual = overrideAvailable
422
+ ? { approval, sandbox: acpRuntimeSandbox(role) }
423
+ : acpModePermissions(mode);
366
424
  const exact = actual.approval === approval && actual.sandbox === sandbox;
367
425
  return {
368
426
  ...translated,
@@ -372,10 +430,9 @@ export function makeCodexAdapter(exec = realExec) {
372
430
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
431
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
432
  + `sandbox=${actual.sandbox}`
375
- : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
- + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
- + `app-server override is unavailable; this does not exactly represent `
378
- + `approval=${approval} sandbox=${sandbox}`],
433
+ : `Codex ACP mode '${mode}' couples approval and filesystem as `
434
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
435
+ + `represent approval=${approval} sandbox=${sandbox}`],
379
436
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
437
  };
381
438
  }
@@ -411,6 +468,8 @@ export function makeCodexAdapter(exec = realExec) {
411
468
  currentIdentityTool: 'current_identity',
412
469
  sendTool: 'send_message',
413
470
  getMessagesTool: 'get_messages',
471
+ listHistoryTool: 'list_history',
472
+ getHistoryItemTool: 'get_history_item',
414
473
  monitorInstruction: (id, configuredRole) => {
415
474
  const consented = configuredRole?.harness_options?.monitor === true;
416
475
  const consent = consented
@@ -1,3 +1,4 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
1
2
  import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
3
  export interface PrereqCheck {
3
4
  name: string;
@@ -21,14 +22,39 @@ export interface SessionPrep {
21
22
  env: Record<string, string>;
22
23
  /** Optional launcher selected after runtime prerequisite probing. */
23
24
  command?: string;
25
+ /**
26
+ * The settings overlay prepareSession wrote, if it wrote one.
27
+ *
28
+ * The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
29
+ * takes no flags, so it needs the PATH rather than the flag. Recorded here so
30
+ * the two deliveries read one value instead of each re-deriving the filename.
31
+ */
32
+ settingsOverlay?: string;
33
+ /**
34
+ * The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
35
+ * if the role declared any. Same reason as `settingsOverlay`: the tmux launch
36
+ * passes the file, the ACP launch has to send the servers themselves.
37
+ */
38
+ mcpConfigFile?: string;
24
39
  }
40
+ /**
41
+ * One MCP server as ACP's `session/new` declares it.
42
+ *
43
+ * ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
44
+ * goes onto the wire unchanged, so a hand-written near-copy would compile while
45
+ * being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
46
+ * variant is the one with no `type` field at all. Aliasing it also keeps
47
+ * `session/new`'s response type inferable, which a structural stand-in silently
48
+ * broke (every field of the result degraded to `unknown`).
49
+ */
50
+ export type AcpMcpServer = McpServer;
25
51
  export interface Launch {
26
52
  argv: string[];
27
53
  env: Record<string, string>;
28
54
  }
29
- export interface AcpLaunch {
30
- argv: string[];
31
- env: Record<string, string>;
55
+ export interface AcpLaunch extends Launch {
56
+ /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
57
+ permissionMetadataSource?: 'codex-acp';
32
58
  }
33
59
  /**
34
60
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -67,6 +93,8 @@ export interface BriefingVocab {
67
93
  currentIdentityTool: string;
68
94
  sendTool: string;
69
95
  getMessagesTool: string;
96
+ listHistoryTool: string;
97
+ getHistoryItemTool: string;
70
98
  monitorInstruction(identity: string, role?: ResolvedRole): string;
71
99
  /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
72
100
  supervisedWakeNote(identity: string, role?: ResolvedRole): string;
@@ -74,7 +102,7 @@ export interface BriefingVocab {
74
102
  restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
75
103
  }
76
104
  /**
77
- * How a harness's host state splits for sandboxing (5.1). `home` is the
105
+ * How a harness's host state splits for sandboxing. `home` is the
78
106
  * directory the CLI treats as its own and whose RUNTIME state must be per-role;
79
107
  * `shared` are the credential, instruction and configuration paths that stay
80
108
  * shared and become read-only inside the sandbox.
@@ -95,7 +123,13 @@ export interface HarnessAdapter {
95
123
  id: string;
96
124
  supportsResume: boolean;
97
125
  checkPrereqs(): Promise<PrereqReport>;
98
- validateOptions(opts: unknown): ValidationError[];
126
+ /**
127
+ * `role` is the SESSION-AWARE half: some harness options can only be honoured
128
+ * on some session types, and an option that is silently dropped is worse than
129
+ * one that is refused. Optional so an adapter that has nothing session-specific
130
+ * to say keeps its one-argument implementation.
131
+ */
132
+ validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
99
133
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
100
134
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
101
135
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
@@ -105,6 +139,23 @@ export interface HarnessAdapter {
105
139
  * agent's default. Omit for a harness whose ACP agent has no modes.
106
140
  */
107
141
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
142
+ /**
143
+ * The MCP servers this role declares, for the `mcpServers` array of ACP's
144
+ * `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
145
+ * configuration alone, which is what fleet has always sent.
146
+ */
147
+ acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
148
+ /**
149
+ * Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
150
+ * a flag reaches an ACP agent that accepts no flags.
151
+ *
152
+ * ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
153
+ * so what an adapter puts here is only honoured by the agent it was written
154
+ * for. An adapter must therefore return nothing for an ACP command it did not
155
+ * choose, and the options that depend on it must be refused at validation for
156
+ * such a role rather than sent and silently ignored.
157
+ */
158
+ acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
108
159
  /** Effective portable policy and harness-native approval mode after native overrides win. */
109
160
  effectivePermissionMode?(role: ResolvedRole): {
110
161
  fleetMode: FleetPermissionMode;
@@ -127,7 +178,7 @@ export interface HarnessAdapter {
127
178
  nativePermissionOverrides(options: unknown): Record<string, unknown>;
128
179
  /**
129
180
  * Host paths this harness needs inside a sandbox, split into a per-role
130
- * writable home and shared read-only credentials/config (5.1). Omit for a
181
+ * writable home and shared read-only credentials/config. Omit for a
131
182
  * harness with no host state of its own.
132
183
  */
133
184
  isolationPaths?(role: ResolvedRole, dirs: RoleDirs): HarnessIsolationPaths;
@@ -1,10 +1,10 @@
1
1
  import { type Exec } from '../exec.js';
2
2
  import type { IsolationBackend, NetworkMode } from './types.js';
3
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.
4
+ * Only `deny` unshares the network namespace. `broker`
5
+ * keeps the host network so ours messaging through a loopback TCP daemon keeps
6
+ * working; hardening `broker` to `--unshare-net` plus
7
+ * a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
8
8
  */
9
9
  export declare function unsharesNet(network: NetworkMode): boolean;
10
10
  export declare function makeBubblewrapBackend(exec?: Exec): IsolationBackend;
@@ -1,9 +1,9 @@
1
1
  import { realExec } from '../exec.js';
2
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.
3
+ * Only `deny` unshares the network namespace. `broker`
4
+ * keeps the host network so ours messaging through a loopback TCP daemon keeps
5
+ * working; hardening `broker` to `--unshare-net` plus
6
+ * a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
7
7
  */
8
8
  export function unsharesNet(network) {
9
9
  return network === 'deny';
@@ -23,7 +23,7 @@ export declare function mountConflict(mount: string, forbidden: string): 'exact'
23
23
  */
24
24
  export declare function validateIsolationConfig(raw: unknown): string[];
25
25
  /**
26
- * Where a role's per-role harness runtime state lives (5.1). Under the agent's
26
+ * Where a role's per-role harness runtime state lives. Under the agent's
27
27
  * own state directory, so it is covered by the state dir's existing lifecycle
28
28
  * and by the forbidden-path exception, and is never shared with a peer.
29
29
  */
@@ -105,7 +105,7 @@ export function validateIsolationConfig(raw) {
105
105
  return problems;
106
106
  }
107
107
  /**
108
- * Where a role's per-role harness runtime state lives (5.1). Under the agent's
108
+ * Where a role's per-role harness runtime state lives. Under the agent's
109
109
  * own state directory, so it is covered by the state dir's existing lifecycle
110
110
  * and by the forbidden-path exception, and is never shared with a peer.
111
111
  */
@@ -147,7 +147,7 @@ export function resolveIsolation(cfg, ctx) {
147
147
  addRw(stateDir);
148
148
  addRw(runCwd);
149
149
  if (ctx.harnessHome && ctx.harnessRuntimeDir) {
150
- // The harness home is backed by a PER-ROLE directory (5.1): the agent gets a
150
+ // The harness home is backed by a PER-ROLE directory: the agent gets a
151
151
  // writable home for its sessions, caches and history, and anything a future
152
152
  // CLI version writes lands there too. The shared credentials, global
153
153
  // instructions and configuration are then layered back read-only, so they
@@ -11,12 +11,12 @@ export interface Selection {
11
11
  detail: string;
12
12
  }
13
13
  /**
14
- * Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
15
- * rootless OQ-5) and the `on_unavailable` degradation policy.
14
+ * Pick the isolation backend for a resolved policy, honouring rootless,
15
+ * bwrap-first `auto` and the `on_unavailable` degradation policy.
16
16
  *
17
17
  * - `none` → the identity backend.
18
18
  * - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
19
- * - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
19
+ * - `podman` → not implemented ⇒ treated as unavailable.
20
20
  * - `auto` → bwrap if available, else degrade/refuse.
21
21
  *
22
22
  * On `on_unavailable: strict` with nothing available, throws (fail closed).
@@ -4,12 +4,12 @@ import { makeNoneBackend } from './none.js';
4
4
  export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
5
5
  export { makeNoneBackend } from './none.js';
6
6
  /**
7
- * Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
8
- * rootless OQ-5) and the `on_unavailable` degradation policy.
7
+ * Pick the isolation backend for a resolved policy, honouring rootless,
8
+ * bwrap-first `auto` and the `on_unavailable` degradation policy.
9
9
  *
10
10
  * - `none` → the identity backend.
11
11
  * - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
12
- * - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
12
+ * - `podman` → not implemented ⇒ treated as unavailable.
13
13
  * - `auto` → bwrap if available, else degrade/refuse.
14
14
  *
15
15
  * On `on_unavailable: strict` with nothing available, throws (fail closed).
@@ -20,9 +20,9 @@ export async function selectIsolationBackend(policy, exec = realExec) {
20
20
  const candidates = [];
21
21
  if (policy.backend === 'auto' || policy.backend === 'bubblewrap')
22
22
  candidates.push(makeBubblewrapBackend(exec));
23
- // podman: Phase 6 — no candidate yet, so it falls through to on_unavailable.
23
+ // podman has no candidate yet, so it falls through to on_unavailable.
24
24
  let lastDetail = policy.backend === 'podman'
25
- ? 'podman backend not implemented yet (Phase 6)'
25
+ ? 'podman backend is not implemented'
26
26
  : 'no isolation backend available';
27
27
  for (const b of candidates) {
28
28
  const a = await b.available();
@@ -5,7 +5,7 @@ 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): a tmux pane is
8
+ * cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
9
9
  * a child of a tmux SERVER rather than of the role's own runner process, so the
10
10
  * only reliable per-agent limit is a transient scope at the pane itself. (Since
11
11
  * #32 that server is per role rather than fleet-wide, which is what keeps one
@@ -1,7 +1,7 @@
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): a tmux pane is
4
+ * cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
5
5
  * a child of a tmux SERVER rather than of the role's own runner process, so the
6
6
  * only reliable per-agent limit is a transient scope at the pane itself. (Since
7
7
  * #32 that server is per role rather than fleet-wide, which is what keeps one
@@ -1,4 +1,4 @@
1
- /** Isolation backend selector. `auto` probes bubblewrap then podman; `none` disables wrapping. */
1
+ /** Isolation backend selector. `auto` probes bubblewrap; `none` disables wrapping. */
2
2
  export type IsolationBackendId = 'auto' | 'bubblewrap' | 'podman' | 'none';
3
3
  /** What to do when the requested backend is unavailable. */
4
4
  export type OnUnavailable = 'warn' | 'strict';
@@ -38,7 +38,7 @@ export interface Mount {
38
38
  mode: 'ro' | 'rw';
39
39
  }
40
40
  /**
41
- * Runtime facts the pure resolver needs to compute the durable mount set (§5.2):
41
+ * Runtime facts the pure resolver needs to compute the durable mount set:
42
42
  * the agent's state dir, its working dir, the fleet user's home, and (if the ours
43
43
  * broker exposes one) a unix-socket endpoint to bind in.
44
44
  */
@@ -52,7 +52,7 @@ export interface WrapContext {
52
52
  additionalWriteDirs?: string[];
53
53
  /**
54
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).
55
+ * from `harnessRuntimeDir` so the agent's own runtime state is per-role.
56
56
  */
57
57
  harnessHome?: string;
58
58
  /** Per-role writable directory backing `harnessHome` inside the sandbox. */
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
68
68
  private armAbandon;
69
69
  private finish;
70
70
  private advance;
71
+ /**
72
+ * Coalesce a backlog into one skip. The counters alone say how many
73
+ * occurrences were lost but never when or for how long, so the window is
74
+ * recorded too and carried on the state until a run is actually told about it
75
+ * — a dropped pass has to stay visible to the next one, not just to whoever
76
+ * was reading the log at the time.
77
+ */
71
78
  private skipMissed;
72
- private skipRestartMisses;
79
+ /**
80
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
81
+ * would still have run. `poll` tolerates lateness up to one full interval and
82
+ * runs the tick late; this path used to drop anything already due however
83
+ * recently, so a role restarted seconds after its own tick came due lost it
84
+ * outright. For an oversight role that is precisely the pass which would have
85
+ * recorded why it restarted, so the failure erased its own witness.
86
+ *
87
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
88
+ * interval deep is still coalesced into a single skip and never replayed —
89
+ * after a long outage exactly one occurrence survives, and `schedule` then
90
+ * arms it through the ordinary path rather than firing a burst here.
91
+ *
92
+ * Running the survivor late cannot outpace the configured cadence: `advance`
93
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
94
+ * time, so a loop that keeps restarting still runs at most once per interval.
95
+ */
96
+ private skipRestartBacklog;
73
97
  /**
74
98
  * A run the store could not record is dropped, not retried: the cursor has
75
99
  * already moved, so this can never become a busy loop, and the outage is
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
86
110
  * until the process was restarted.
87
111
  */
88
112
  private recover;
113
+ /**
114
+ * The envelope is the only channel a scheduled pass has for learning about
115
+ * the passes that did not happen. A gap stated here is what lets an oversight
116
+ * role report its own outage instead of resuming as if nothing was missed.
117
+ */
89
118
  private envelope;
90
119
  }
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
39
39
  }
40
40
  start() {
41
41
  if (!this.store.fresh)
42
- this.skipRestartMisses();
42
+ this.skipRestartBacklog();
43
43
  this.schedule();
44
44
  }
45
45
  async stop() {
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
143
143
  async attempt(definition, state, scheduledAt) {
144
144
  const runId = `sl_${randomUUID()}`;
145
145
  const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
146
- const prompt = this.envelope(definition, runId, scheduledAt);
146
+ // The gap is read here and cleared only if the turn is actually admitted:
147
+ // an attempt that ends `skipped_busy` or `unavailable` reported it to
148
+ // nobody, so it has to still be there for the attempt that succeeds.
149
+ const gap = state.missedGap;
150
+ const prompt = this.envelope(definition, runId, scheduledAt, gap);
147
151
  let claimed = false;
148
152
  const result = await this.arbiter.tryScheduled(prompt, origin, () => {
149
153
  claimed = true;
154
+ state.missedGap = null;
150
155
  state.activeRunId = runId;
151
156
  state.lastRunId = runId;
152
157
  state.lastStartedAt = new Date(this.deps.now()).toISOString();
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
265
270
  state.nextScheduledAt = new Date(next).toISOString();
266
271
  state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
267
272
  }
273
+ /**
274
+ * Coalesce a backlog into one skip. The counters alone say how many
275
+ * occurrences were lost but never when or for how long, so the window is
276
+ * recorded too and carried on the state until a run is actually told about it
277
+ * — a dropped pass has to stay visible to the next one, not just to whoever
278
+ * was reading the log at the time.
279
+ */
268
280
  skipMissed(definition, state, now) {
281
+ const from = state.nextScheduledAt;
269
282
  let missed = 0;
270
283
  while (Date.parse(state.nextDueAt) <= now) {
271
284
  this.advance(definition, state);
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
275
288
  state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
276
289
  state.lastOutcome = 'skipped_missed';
277
290
  state.lastFinishedAt = new Date(now).toISOString();
291
+ // Successive outages before any run lands merge into one gap: the earliest
292
+ // start wins, so the window always spans the whole silence.
293
+ const previous = state.missedGap;
294
+ state.missedGap = {
295
+ count: increment(previous?.count ?? 0, missed),
296
+ fromAt: previous?.fromAt ?? from,
297
+ throughAt: state.lastScheduledAt ?? from,
298
+ detectedAt: new Date(now).toISOString(),
299
+ };
278
300
  this.store.persist();
279
- this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
301
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
302
+ + `gap=${from}..${state.missedGap.throughAt} `
303
+ + `unreported=${state.missedGap.count}`);
280
304
  }
281
- skipRestartMisses() {
305
+ /**
306
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
307
+ * would still have run. `poll` tolerates lateness up to one full interval and
308
+ * runs the tick late; this path used to drop anything already due however
309
+ * recently, so a role restarted seconds after its own tick came due lost it
310
+ * outright. For an oversight role that is precisely the pass which would have
311
+ * recorded why it restarted, so the failure erased its own witness.
312
+ *
313
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
314
+ * interval deep is still coalesced into a single skip and never replayed —
315
+ * after a long outage exactly one occurrence survives, and `schedule` then
316
+ * arms it through the ordinary path rather than firing a burst here.
317
+ *
318
+ * Running the survivor late cannot outpace the configured cadence: `advance`
319
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
320
+ * time, so a loop that keeps restarting still runs at most once per interval.
321
+ */
322
+ skipRestartBacklog() {
282
323
  const now = this.deps.now();
283
324
  for (const definition of this.definitions.values()) {
284
325
  const state = this.store.state.loops[definition.name];
285
- if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
326
+ if (definition.enabled && !state.operatorDisabled
327
+ && now >= Date.parse(state.nextDueAt) + definition.intervalMs)
286
328
  this.skipMissed(definition, state, now);
287
329
  }
288
330
  }
@@ -340,17 +382,38 @@ export class ScheduledLoopManager {
340
382
  this.deps.clearTimer(this.timer);
341
383
  this.arm(backoffMs(this.pollFailures));
342
384
  }
343
- envelope(definition, runId, scheduledAt) {
385
+ /**
386
+ * The envelope is the only channel a scheduled pass has for learning about
387
+ * the passes that did not happen. A gap stated here is what lets an oversight
388
+ * role report its own outage instead of resuming as if nothing was missed.
389
+ */
390
+ envelope(definition, runId, scheduledAt, gap) {
391
+ const lateBy = Math.max(0, this.deps.now() - scheduledAt);
344
392
  return [
345
393
  '[fleet-loop]',
346
394
  `loop: ${definition.name}`,
347
395
  `run: ${runId}`,
348
396
  `scheduled_at: ${new Date(scheduledAt).toISOString()}`,
397
+ ...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
398
+ ...(gap ? [
399
+ `missed_occurrences: ${gap.count}`,
400
+ `missed_window: ${gap.fromAt}..${gap.throughAt}`,
401
+ `missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
402
+ ] : []),
349
403
  'origin: local-trusted-config',
350
404
  '',
351
405
  'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
352
406
  'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
353
407
  'configured policy and an existing authenticated proactive-report route authorize a material report.',
408
+ // Same single route as the owner-request prompt, and for the same reason.
409
+ 'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
410
+ 'identity if this role has one, otherwise directly to the contact who should receive it.',
411
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
412
+ ...(gap ? ['',
413
+ 'This loop did not run for the window above: those occurrences were coalesced away while the role',
414
+ 'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
415
+ 'on — it is the record of your own outage, and no later pass will be told about it.',
416
+ ] : []),
354
417
  '',
355
418
  definition.prompt,
356
419
  ].join('\n');
@@ -8,11 +8,29 @@ export interface LoopCounts {
8
8
  skippedBusy: number;
9
9
  skippedMissed: number;
10
10
  }
11
+ /**
12
+ * A coalesced run of occurrences that were never submitted, held until a run
13
+ * actually starts and can be told about it. Without it a dropped occurrence
14
+ * survives only as a counter, which says how many were lost but never when or
15
+ * for how long — and an oversight role cannot report an outage it cannot date.
16
+ */
17
+ export interface LoopMissedGap {
18
+ /** Occurrences coalesced away, summed across every skip since the last run. */
19
+ count: number;
20
+ /** Nominal time of the earliest occurrence in the gap. */
21
+ fromAt: string;
22
+ /** Nominal time of the latest occurrence in the gap. */
23
+ throughAt: string;
24
+ /** When the manager noticed — the end of the outage, not of the last skip. */
25
+ detectedAt: string;
26
+ }
11
27
  export interface LoopRuntimeState {
12
28
  definitionHash: string;
13
29
  promptHash: string;
14
30
  enabled: boolean;
15
31
  operatorDisabled: boolean;
32
+ /** Unreported gap, cleared by the first run that carries it. */
33
+ missedGap: LoopMissedGap | null;
16
34
  nextScheduledAt: string;
17
35
  nextDueAt: string;
18
36
  lastScheduledAt: string | null;