@ours.network/fleet 1.2.0-nightly.2 → 1.2.0-nightly.21

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 (277) hide show
  1. package/README.md +152 -29
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/correspondence.d.ts +10 -0
  7. package/dist/agent-ours/correspondence.js +1 -0
  8. package/dist/agent-ours/file-rpc.d.ts +23 -0
  9. package/dist/agent-ours/file-rpc.js +123 -0
  10. package/dist/agent-ours/harness.d.ts +14 -0
  11. package/dist/agent-ours/harness.js +72 -0
  12. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  13. package/dist/agent-ours/mcp-endpoint.js +111 -0
  14. package/dist/agent-ours/runtime.d.ts +59 -0
  15. package/dist/agent-ours/runtime.js +283 -0
  16. package/dist/agent-ours/service.d.ts +23 -0
  17. package/dist/agent-ours/service.js +372 -0
  18. package/dist/agent-ours/state.d.ts +45 -0
  19. package/dist/agent-ours/state.js +95 -0
  20. package/dist/agent-ours/wire.d.ts +14 -0
  21. package/dist/agent-ours/wire.js +55 -0
  22. package/dist/agent-recovery-gate.js +1 -1
  23. package/dist/application/conversation-history.d.ts +6 -0
  24. package/dist/application/conversation-history.js +65 -0
  25. package/dist/application/fleet-query-service.d.ts +1 -1
  26. package/dist/application/fleet-query-service.js +2 -2
  27. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  28. package/dist/application/legacy-supervisor-identity.js +43 -0
  29. package/dist/application/model-catalog.d.ts +1 -1
  30. package/dist/application/model-catalog.js +5 -2
  31. package/dist/application/preset-provenance.d.ts +19 -0
  32. package/dist/application/preset-provenance.js +59 -0
  33. package/dist/application/room-layout-definitions.d.ts +45 -0
  34. package/dist/application/room-layout-definitions.js +130 -0
  35. package/dist/application/session-control.d.ts +8 -0
  36. package/dist/application/session-control.js +21 -3
  37. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  38. package/dist/application/supervisor-ours-tools.js +113 -0
  39. package/dist/application/task-layouts.d.ts +89 -0
  40. package/dist/application/task-layouts.js +248 -0
  41. package/dist/application/task-room-service.d.ts +99 -3
  42. package/dist/application/task-room-service.js +296 -31
  43. package/dist/atomic-file.d.ts +5 -0
  44. package/dist/atomic-file.js +62 -1
  45. package/dist/briefing.js +10 -59
  46. package/dist/build-info.json +5 -4
  47. package/dist/capabilities.d.ts +2 -1
  48. package/dist/capabilities.js +2 -0
  49. package/dist/cli.js +65 -2
  50. package/dist/client-profile.d.ts +5 -16
  51. package/dist/client-profile.js +4 -112
  52. package/dist/config.d.ts +4 -0
  53. package/dist/config.js +7 -0
  54. package/dist/creation.d.ts +1 -1
  55. package/dist/creation.js +5 -16
  56. package/dist/daemon-recovery.d.ts +1 -8
  57. package/dist/daemon-recovery.js +8 -113
  58. package/dist/docs.d.ts +3 -3
  59. package/dist/docs.js +43 -23
  60. package/dist/doctor.d.ts +1 -1
  61. package/dist/doctor.js +46 -59
  62. package/dist/erased-resources.d.ts +9 -0
  63. package/dist/erased-resources.js +87 -0
  64. package/dist/fleet-command-audit.d.ts +1 -0
  65. package/dist/fleet-command-audit.js +12 -7
  66. package/dist/harness/agent-session.d.ts +2 -0
  67. package/dist/harness/claude-code-session.js +4 -2
  68. package/dist/harness/codex-app-server-proxy.d.ts +4 -1
  69. package/dist/harness/codex-app-server-proxy.js +41 -5
  70. package/dist/harness/codex-session.js +9 -2
  71. package/dist/harness/codex.d.ts +3 -2
  72. package/dist/harness/codex.js +4 -3
  73. package/dist/harness/hermes-session.js +7 -1
  74. package/dist/init-wizard.js +20 -9
  75. package/dist/monitor.d.ts +1 -34
  76. package/dist/monitor.js +24 -151
  77. package/dist/owner-channel/channel.d.ts +3 -0
  78. package/dist/owner-channel/channel.js +34 -6
  79. package/dist/owner-channel/commands.d.ts +4 -0
  80. package/dist/owner-channel/commands.js +8 -0
  81. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  82. package/dist/owner-channel/ours-client.js +2 -17
  83. package/dist/preset-migration.js +15 -0
  84. package/dist/rooms-tasks/add-member.d.ts +48 -0
  85. package/dist/rooms-tasks/add-member.js +83 -0
  86. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  87. package/dist/rooms-tasks/archived-absence.js +48 -0
  88. package/dist/rooms-tasks/cli.js +34 -5
  89. package/dist/rooms-tasks/close.d.ts +5 -2
  90. package/dist/rooms-tasks/close.js +208 -28
  91. package/dist/rooms-tasks/config.js +3 -1
  92. package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
  93. package/dist/rooms-tasks/cowork-adapter.js +11 -117
  94. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  95. package/dist/rooms-tasks/cowork-http.js +70 -0
  96. package/dist/rooms-tasks/deletion.d.ts +1 -1
  97. package/dist/rooms-tasks/deletion.js +74 -5
  98. package/dist/rooms-tasks/erasure.d.ts +6 -0
  99. package/dist/rooms-tasks/erasure.js +169 -0
  100. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  101. package/dist/rooms-tasks/external-worker.js +3 -4
  102. package/dist/rooms-tasks/layout-cli.d.ts +2 -0
  103. package/dist/rooms-tasks/layout-cli.js +81 -0
  104. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  105. package/dist/rooms-tasks/layout-config.js +89 -0
  106. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  107. package/dist/rooms-tasks/layout-control.js +52 -0
  108. package/dist/rooms-tasks/layout-service.d.ts +39 -0
  109. package/dist/rooms-tasks/layout-service.js +173 -0
  110. package/dist/rooms-tasks/layout.d.ts +102 -0
  111. package/dist/rooms-tasks/layout.js +227 -0
  112. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  113. package/dist/rooms-tasks/live-readiness.js +85 -0
  114. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  115. package/dist/rooms-tasks/member-startup.js +2 -0
  116. package/dist/rooms-tasks/provision.d.ts +14 -0
  117. package/dist/rooms-tasks/provision.js +182 -48
  118. package/dist/rooms-tasks/room-state.d.ts +4 -2
  119. package/dist/rooms-tasks/room-state.js +53 -8
  120. package/dist/rooms-tasks/task-state.d.ts +42 -7
  121. package/dist/rooms-tasks/task-state.js +153 -23
  122. package/dist/rooms-tasks/types.d.ts +28 -0
  123. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  124. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  125. package/dist/rooms-tasks/workspace.d.ts +26 -0
  126. package/dist/rooms-tasks/workspace.js +195 -0
  127. package/dist/runner.d.ts +23 -3
  128. package/dist/runner.js +574 -494
  129. package/dist/session/acp.d.ts +3 -0
  130. package/dist/session/acp.js +7 -0
  131. package/dist/session/arbiter.d.ts +3 -0
  132. package/dist/session/arbiter.js +20 -2
  133. package/dist/session/codex-app-server-transport.js +1 -1
  134. package/dist/session/codex-app-server.d.ts +3 -0
  135. package/dist/session/codex-app-server.js +25 -1
  136. package/dist/session/control.d.ts +10 -1
  137. package/dist/session/control.js +36 -2
  138. package/dist/session/conversation-normalizer.d.ts +2 -1
  139. package/dist/session/conversation-normalizer.js +1 -1
  140. package/dist/session/conversation-types.d.ts +5 -3
  141. package/dist/session/task-notice.d.ts +14 -0
  142. package/dist/session/task-notice.js +13 -0
  143. package/dist/session/types.d.ts +3 -0
  144. package/dist/socket-path.d.ts +2 -0
  145. package/dist/socket-path.js +22 -0
  146. package/dist/spawn.js +19 -16
  147. package/dist/startup-readiness.d.ts +13 -0
  148. package/dist/startup-readiness.js +38 -0
  149. package/dist/supervisor/launchd.js +2 -1
  150. package/dist/supervisor/systemd.js +9 -23
  151. package/dist/temp-idle.d.ts +17 -0
  152. package/dist/temp-idle.js +42 -0
  153. package/dist/temp-lifecycle.d.ts +6 -1
  154. package/dist/temp-lifecycle.js +29 -13
  155. package/dist/temp-supervisor-recovery.d.ts +3 -0
  156. package/dist/temp-supervisor-recovery.js +46 -0
  157. package/dist/watchdog/briefing.js +3 -16
  158. package/dist/watchdog/run.js +19 -5
  159. package/dist/web/agent-attachments.d.ts +29 -0
  160. package/dist/web/agent-attachments.js +162 -0
  161. package/dist/web/auth.d.ts +4 -1
  162. package/dist/web/auth.js +23 -2
  163. package/dist/web/control.js +2 -1
  164. package/dist/web/fleet-config-service.d.ts +4 -1
  165. package/dist/web/fleet-config-service.js +46 -15
  166. package/dist/web/prefix-gateway.d.ts +20 -0
  167. package/dist/web/prefix-gateway.js +150 -0
  168. package/dist/web/runtime.d.ts +4 -0
  169. package/dist/web/runtime.js +19 -14
  170. package/dist/web/server.d.ts +7 -0
  171. package/dist/web/server.js +293 -25
  172. package/docs/room-layouts.md +92 -0
  173. package/docs/task-workspaces.md +130 -0
  174. package/examples/room-layouts/fresh.yaml +27 -0
  175. package/examples/room-layouts/shared.yaml +19 -0
  176. package/package.json +17 -13
  177. package/presets/README.md +78 -0
  178. package/presets/brain-catalog.json +8 -2
  179. package/presets/fleet/agent_templates/Engineer.yaml +5 -0
  180. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  181. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  182. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  183. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  184. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  185. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  186. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  187. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  188. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  189. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  190. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  191. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  192. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  193. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  194. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  195. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  196. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  197. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  198. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  199. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  200. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  201. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  202. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  203. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  204. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  205. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  206. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  207. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  208. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  209. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  210. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  211. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  212. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  213. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  214. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  215. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  216. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  217. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  218. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  219. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  220. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  221. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  222. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  223. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  224. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  225. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  226. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  227. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  228. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  229. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  230. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  231. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  232. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  233. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  234. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  235. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  236. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  237. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  238. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  239. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
  240. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
  241. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
  242. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
  243. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
  244. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
  245. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  246. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  247. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  248. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  249. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  250. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  251. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  252. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  253. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  254. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  255. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  256. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  257. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  258. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  259. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  260. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  261. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  262. package/presets/fleet/roles/Coordinator.yaml +22 -0
  263. package/presets/fleet/roles/Engineer.yaml +40 -0
  264. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  265. package/presets/fleet/room_layouts/pair.yaml +24 -0
  266. package/presets/fleet/room_layouts/single.yaml +17 -0
  267. package/presets/fleet/room_layouts/team.yaml +30 -0
  268. package/presets/fleet/room_templates/engineering.yaml +19 -0
  269. package/presets/manifest.json +2 -2
  270. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  271. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  272. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  273. package/dist/web-app/icons/ours-fleet.svg +0 -4
  274. package/dist/web-app/index.html +0 -17
  275. package/dist/web-app/manifest.webmanifest +0 -15
  276. package/dist/web-app/offline.html +0 -18
  277. package/dist/web-app/sw.js +0 -51
@@ -1,3 +1,4 @@
1
+ import { clientConfigPath } from '../client-profile.js';
1
2
  import { mkdirSync, writeFileSync } from 'node:fs';
2
3
  import { delimiter, dirname, join } from 'node:path';
3
4
  import { userInfo } from 'node:os';
@@ -69,30 +70,10 @@ export function makeSystemdBackend(exec = realExec) {
69
70
  dirname(process.execPath),
70
71
  ...(process.env.PATH ?? '').split(delimiter),
71
72
  ].filter(Boolean))].join(delimiter);
72
- // Same reason as PATH, for the other thing a lingering unit cannot inherit:
73
- // WHICH ours daemon this fleet was set up against.
74
- //
75
- // ours-fleet resolves its daemon from OURS_CONFIG / OURS_PORT / OURS_STATE_DIR
76
- // and otherwise falls back to ~/.ours and port 3050 (src/monitor.ts). A host
77
- // set up against a non-default daemon — the installer's multi-daemon profiles
78
- // do exactly this — passes that selection to `ours-fleet init` in the
79
- // environment, and it died there: nothing persisted it, so every runner
80
- // systemd started at boot resolved the default daemon again, and on a host
81
- // where only the non-default daemon exists that is a daemon that is not there.
82
- //
83
- // ONLY OURS_CONFIG is baked, deliberately. It names which daemon, and leaves
84
- // that daemon's own config file authoritative for port and state directory, so
85
- // a later edit to it still wins. Baking OURS_PORT/OURS_STATE_DIR would freeze
86
- // those into a unit file that outranks the config for ever after — the failure
87
- // mode @ours.network/cli avoids for the same reason (packages/cli/src/
88
- // service.ts: "The port is deliberately NOT baked").
89
- //
90
- // Absent from init's environment ⇒ no line, and the unit is byte-for-byte what
91
- // it has always been. This teaches fleet nothing about installer profiles; it
92
- // persists the selection fleet was initialised with.
73
+ // Persist one profile path, never a URL or service port. Profile updates
74
+ // are picked up by every fresh process, including lingering user services.
93
75
  const unitEnv = ['PATH=' + servicePath];
94
- if (process.env.OURS_CONFIG)
95
- unitEnv.push('OURS_CONFIG=' + process.env.OURS_CONFIG);
76
+ unitEnv.push('OURS_CONFIG=' + clientConfigPath(process.env));
96
77
  const environmentLines = unitEnv
97
78
  .map(value => `Environment="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`)
98
79
  .join('\n');
@@ -100,10 +81,15 @@ export function makeSystemdBackend(exec = realExec) {
100
81
  writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
101
82
  Description=ours-fleet agent %i
102
83
  After=default.target
84
+ StartLimitIntervalSec=0
103
85
 
104
86
  [Service]
105
87
  Type=simple
106
88
  ${environmentLines}
89
+ # Wait outside the runner so boot-time daemon unavailability cannot consume
90
+ # the agent crash budget. Selection comes from the same role and profile.
91
+ ExecStartPre=${unitArg(process.execPath)} ${unitArg(binPath)} _wait-daemon %i
92
+ TimeoutStartSec=270
107
93
  ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
108
94
  # The RUNNER owns the child-session restart loop, with a counted, backed-off
109
95
  # circuit breaker. systemd must only recover the runner PROCESS crashing —
@@ -0,0 +1,17 @@
1
+ import type { SessionSnapshot } from './session/types.js';
2
+ export declare const TEMP_CHAT_IDLE_MS: number;
3
+ export type ChatIdlePolicy = {
4
+ version: 1;
5
+ timeoutMs: number;
6
+ lastActivityAt: number;
7
+ };
8
+ export declare function readChatIdle(dir: string): ChatIdlePolicy | undefined;
9
+ /** Only a standalone temporary supervisor owns this clock; browser reads never touch it. */
10
+ export declare class TemporaryChatIdle {
11
+ private dir;
12
+ private value;
13
+ private busy;
14
+ constructor(dir: string, now: number);
15
+ private save;
16
+ observe(snapshot: SessionSnapshot, queueDepth: number, now: number): boolean;
17
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically } from './atomic-file.js';
4
+ export const TEMP_CHAT_IDLE_MS = 24 * 60 * 60_000;
5
+ const FILE = 'chat-idle.json';
6
+ export function readChatIdle(dir) {
7
+ if (!existsSync(join(dir, FILE)))
8
+ return undefined;
9
+ const value = JSON.parse(readFileSync(join(dir, FILE), 'utf8'));
10
+ if (value.version !== 1 || !Number.isSafeInteger(value.lastActivityAt) || value.timeoutMs !== TEMP_CHAT_IDLE_MS)
11
+ throw new Error('Invalid persisted chat idle policy');
12
+ return value;
13
+ }
14
+ /** Only a standalone temporary supervisor owns this clock; browser reads never touch it. */
15
+ export class TemporaryChatIdle {
16
+ dir;
17
+ value;
18
+ busy = false;
19
+ constructor(dir, now) {
20
+ this.dir = dir;
21
+ this.value = readChatIdle(dir) ?? { version: 1, timeoutMs: TEMP_CHAT_IDLE_MS, lastActivityAt: now };
22
+ this.save();
23
+ }
24
+ save() { replaceFileAtomically(join(this.dir, FILE), JSON.stringify(this.value) + '\n', 0o600); }
25
+ observe(snapshot, queueDepth, now) {
26
+ // Unknown activity evidence never grants permission to retire a session.
27
+ const idle = snapshot.alive && snapshot.readiness === 'idle' && !snapshot.pendingPermissionId && queueDepth === 0 && snapshot.activity?.activeToolCalls === 0;
28
+ const update = Date.parse(snapshot.activity?.lastUpdateAt ?? '');
29
+ let next = this.value.lastActivityAt;
30
+ if (Number.isFinite(update) && update <= now)
31
+ next = Math.max(next, update);
32
+ if (!idle || this.busy)
33
+ next = Math.max(next, now);
34
+ this.busy = !idle;
35
+ // Active work checkpoints at most once a minute, with an immediate idle boundary write.
36
+ if (next > this.value.lastActivityAt && (idle || next - this.value.lastActivityAt >= 60_000)) {
37
+ this.value.lastActivityAt = next;
38
+ this.save();
39
+ }
40
+ return idle && now >= this.value.lastActivityAt && now - this.value.lastActivityAt >= this.value.timeoutMs;
41
+ }
42
+ }
@@ -5,7 +5,7 @@ export declare const TEMP_STOP_REQUEST_FILE = ".temp-stop-request.json";
5
5
  export declare const TEMP_RECLAIM_BATCH = 32;
6
6
  export declare const TEMP_LAUNCH_GRACE_MS = 60000;
7
7
  export type TempSupervisorKind = 'systemd-transient' | 'launchd-transient' | 'detached';
8
- export type TempTerminationReason = 'identity-closed' | 'session-ended' | 'operator-stop' | 'supervisor-signal' | 'supervisor-recycle' | 'startup-failure' | 'stale-supervisor';
8
+ export type TempTerminationReason = 'idle-timeout' | 'identity-closed' | 'session-ended' | 'operator-stop' | 'supervisor-signal' | 'supervisor-recycle' | 'startup-failure' | 'stale-supervisor';
9
9
  export interface TempSupervisorRecord {
10
10
  version: 1;
11
11
  role: string;
@@ -44,6 +44,11 @@ export declare function makeTempSupervisorLauncher(options?: {
44
44
  }): SupervisorLauncher;
45
45
  export declare function markTempSupervisorActive(dir: string, pid?: number): Promise<void>;
46
46
  export declare function requestedTempStopReason(dir: string): 'operator-stop' | undefined;
47
+ /** Explicit erasure removes only exact retired role/launch events. */
48
+ export declare function eraseTerminationEvents(launches: ReadonlyArray<{
49
+ role: string;
50
+ launchId: string;
51
+ }>): void;
47
52
  /** Move retired state out of the live roster without deleting any evidence. */
48
53
  export declare function archiveTempState(role: string, reason: TempTerminationReason, outcome: TempTerminationRecord['outcome'], detail: string, now?: Date): string | undefined;
49
54
  /** Resolve only an exact role+launch archive; names and timestamps are never proof. */
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { basename, join } from 'node:path';
4
- import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
+ import { replaceFileAtomically, withFileLock, withSynchronousFileLock } from './atomic-file.js';
5
5
  import { realExec } from './exec.js';
6
6
  import { stateRoot, tmpRoot } from './paths.js';
7
7
  export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
@@ -59,7 +59,7 @@ export function makeTempSupervisorLauncher(options = {}) {
59
59
  const supervisor = options.supervisor ?? process.env.OURS_FLEET_SUPERVISOR;
60
60
  return async (binPath, args, dir) => {
61
61
  const inherited = [
62
- 'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'CODEX_HOME',
62
+ 'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_FLEET_SOCKET_ROOT', 'CODEX_HOME',
63
63
  // Preserve runtime selection across the service-manager boundary, including
64
64
  // an explicit empty value which selects the bundle over manager defaults.
65
65
  'CODEX_PATH',
@@ -133,18 +133,34 @@ function appendTermination(dir, record) {
133
133
  replaceFileAtomically(join(dir, TEMP_GLOBAL_TERMINATION_MARKER), line);
134
134
  }
135
135
  function appendGlobalTermination(line, checkExisting = false) {
136
- mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
137
- const path = join(archiveRoot(), 'terminations.jsonl');
138
- // Recovery may revisit a .retiring directory after a crash between the
139
- // global append and final rename. Avoid duplicating that exact event.
140
- if (checkExisting) {
141
- try {
142
- if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
143
- return;
136
+ withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
137
+ mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
138
+ const path = join(archiveRoot(), 'terminations.jsonl');
139
+ // Recovery may revisit a .retiring directory after a crash between the
140
+ // global append and final rename. Avoid duplicating that exact event.
141
+ if (checkExisting) {
142
+ try {
143
+ if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
144
+ return;
145
+ }
146
+ catch { /* the journal does not exist yet */ }
144
147
  }
145
- catch { /* the journal does not exist yet */ }
146
- }
147
- appendFileSync(path, line, { mode: 0o600 });
148
+ appendFileSync(path, line, { mode: 0o600 });
149
+ });
150
+ }
151
+ /** Explicit erasure removes only exact retired role/launch events. */
152
+ export function eraseTerminationEvents(launches) {
153
+ withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
154
+ const path = join(archiveRoot(), 'terminations.jsonl');
155
+ if (!existsSync(path))
156
+ return;
157
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
158
+ const kept = lines.filter(line => {
159
+ const row = JSON.parse(line);
160
+ return !launches.some(launch => launch.role === row.role && launch.launchId === row.launchId);
161
+ });
162
+ replaceFileAtomically(path, kept.length ? kept.join('\n') + '\n' : '');
163
+ });
148
164
  }
149
165
  /** Pick a sibling path without overwriting evidence from an earlier attempt. */
150
166
  function collisionSafeArchivePaths(targetBase, retiringBase) {
@@ -0,0 +1,3 @@
1
+ export declare const TEMP_RECYCLE_EXIT = 75;
2
+ /** Keep the service's main process alive while replacing a PID-scoped worker. */
3
+ export declare function runTempSupervisor(name: string, entrypoint: string, initial?: () => Promise<void>): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { runTemp, SupervisorRecycleRequiredError } from './runner.js';
3
+ export const TEMP_RECYCLE_EXIT = 75;
4
+ /** Keep the service's main process alive while replacing a PID-scoped worker. */
5
+ export async function runTempSupervisor(name, entrypoint, initial = () => runTemp(name)) {
6
+ try {
7
+ await initial();
8
+ return;
9
+ }
10
+ catch (error) {
11
+ if (!(error instanceof SupervisorRecycleRequiredError))
12
+ throw error;
13
+ }
14
+ for (let attempt = 0; attempt < 3; attempt++) {
15
+ const child = spawn(process.execPath, [entrypoint, '_run-temp-worker', name], {
16
+ stdio: 'inherit',
17
+ env: process.env,
18
+ });
19
+ let stopping = false;
20
+ const term = () => {
21
+ stopping = true;
22
+ child.kill('SIGTERM');
23
+ }, interrupt = () => {
24
+ stopping = true;
25
+ child.kill('SIGINT');
26
+ };
27
+ process.on('SIGTERM', term);
28
+ process.on('SIGINT', interrupt);
29
+ let code;
30
+ try {
31
+ code = await new Promise((resolve, reject) => {
32
+ child.once('error', reject);
33
+ child.once('exit', resolve);
34
+ });
35
+ }
36
+ finally {
37
+ process.off('SIGTERM', term);
38
+ process.off('SIGINT', interrupt);
39
+ }
40
+ if (code === 0 || stopping)
41
+ return;
42
+ if (code !== TEMP_RECYCLE_EXIT)
43
+ throw Error(`Temporary supervisor replacement exited with ${code}`);
44
+ }
45
+ throw Error('Temporary supervisor recycle limit reached; identity retained for explicit recovery');
46
+ }
@@ -5,22 +5,9 @@ import { formatDuration } from '../duration.js';
5
5
  * wasn't verified when configured, fall back to createTool on first run.
6
6
  */
7
7
  function bindSection(heading, wd, v, guarantee) {
8
- const L = ['', heading];
9
- L.push(`Call the **${v.bindTool}** tool with name "${wd.identity}" force=true (search the`);
10
- L.push('deferred tool registry first if needed).');
11
- if (guarantee === 'unverified') {
12
- L.push(`- This identity was NOT verified when this watchdog was configured, so it may not`);
13
- L.push(` exist yet. If binding reports no such identity, call **${v.createTool}** name`);
14
- L.push(` "${wd.identity}" once to mint it on this first run, then you are bound. Re-binding`);
15
- L.push(' your OWN identity is always allowed.');
16
- }
17
- else {
18
- L.push(`- It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when this`);
19
- L.push(' watchdog was configured, so binding should succeed. If it unexpectedly reports no');
20
- L.push(` such identity, call **${v.createTool}** name "${wd.identity}" once and report the`);
21
- L.push(' discrepancy — something removed it since.');
22
- }
23
- return L;
8
+ return ['', '## Assigned identity',
9
+ `Your supervisor has bound your identity **${wd.identity}**. The ours tools are ready.`,
10
+ 'No identity setup or skill reading is required.'];
24
11
  }
25
12
  /**
26
13
  * Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
@@ -1,3 +1,4 @@
1
+ import { preparePermanentAssignment, releaseManagedAgent } from '../agent-ours/service.js';
1
2
  import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
3
  import { spawn as spawnChild } from 'node:child_process';
3
4
  import { join } from 'node:path';
@@ -7,8 +8,7 @@ import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport
7
8
  import { generateNotifierBriefing, generateWatchdogBriefing } from './briefing.js';
8
9
  import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alerts.js';
9
10
  import { applyRole } from '../ops.js';
10
- import { daemonIdentityInventoryProvisioner, ensureIdentity, } from '../creation.js';
11
- import { runOnce, START_STAGGER_FILE } from '../runner.js';
11
+ import { runOnce, loadTempRole, START_STAGGER_FILE } from '../runner.js';
12
12
  import { agentDir, tmpRoot } from '../paths.js';
13
13
  import { loadConfig, ROLE_NAME_RE, } from '../config.js';
14
14
  import { getAdapter } from '../harness/registry.js';
@@ -53,6 +53,7 @@ async function killIfNeeded(reason, child, roleName, sleep) {
53
53
  if (reason === 'stable')
54
54
  await sleep(HARVEST_GRACE_MS);
55
55
  child.kill();
56
+ await child.exited;
56
57
  }
57
58
  }
58
59
  function defaultLaunchChild(binPath, roleName, runDir) {
@@ -139,7 +140,7 @@ export async function executeWatchdogRun(wd, deps) {
139
140
  rmSync(runDir, { recursive: true, force: true });
140
141
  let report;
141
142
  try {
142
- const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityInventoryProvisioner(), deps.log);
143
+ const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
143
144
  const cfg = deps.cfg ?? loadConfig();
144
145
  const discovered = wd.watchExplicit
145
146
  ? []
@@ -272,7 +273,7 @@ export async function executeNotifierRun(wd, text, deps) {
272
273
  // A crashed previous run can leave the temp dir behind; start clean.
273
274
  if (existsSync(runDir))
274
275
  rmSync(runDir, { recursive: true, force: true });
275
- const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityInventoryProvisioner(), deps.log);
276
+ const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
276
277
  const cfg = deps.cfg ?? loadConfig();
277
278
  const role = buildWatchdogRole(wd, cfg);
278
279
  const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
@@ -317,5 +318,18 @@ export async function executeNotifierRun(wd, text, deps) {
317
318
  }
318
319
  /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
319
320
  export async function runWatchdogAgent(name) {
320
- await runOnce(name, { temp: true });
321
+ const role = loadTempRole(name);
322
+ await preparePermanentAssignment(role);
323
+ let stopping = false;
324
+ const stop = () => { stopping = true; };
325
+ process.on('SIGTERM', stop);
326
+ process.on('SIGINT', stop);
327
+ try {
328
+ await runOnce(name, { temp: true, identityLifetime: 'permanent' }, { shouldStop: () => stopping });
329
+ }
330
+ finally {
331
+ process.off('SIGTERM', stop);
332
+ process.off('SIGINT', stop);
333
+ await releaseManagedAgent(role);
334
+ }
321
335
  }
@@ -0,0 +1,29 @@
1
+ export declare const MAX_ATTACHMENT_BYTES: number;
2
+ export declare const MAX_ATTACHMENT_BODY: number;
3
+ export interface AgentAttachment {
4
+ id: string;
5
+ name: string;
6
+ mimeType: string;
7
+ size: number;
8
+ sha256: string;
9
+ generation: string;
10
+ }
11
+ export declare function storeAgentAttachment(stateDir: string, generation: string, input: unknown): AgentAttachment;
12
+ export declare function attachmentPrompt(stateDir: string, generation: string, ids: unknown, text: string): string;
13
+ export declare function readAgentAttachment(stateDir: string, id: string): {
14
+ record: AgentAttachment;
15
+ bytes: Buffer;
16
+ };
17
+ interface AttachmentPresentation {
18
+ generation: string;
19
+ commandId: string;
20
+ text: string;
21
+ transportDigest: string;
22
+ ids: string[];
23
+ }
24
+ /** Written before admission so a lost HTTP response cannot lose the visible message. */
25
+ export declare function storeAttachmentPresentation(stateDir: string, value: AttachmentPresentation): void;
26
+ export declare function prepareAttachmentPresentation(stateDir: string, generation: string, commandId: string, ids: string[], text: string, transport: string): void;
27
+ /** Never infer attachments by stripping user text or interpreting a marker. */
28
+ export declare function presentAttachmentEvent<T>(stateDir: string | undefined, roleId: string, event: T): T;
29
+ export {};
@@ -0,0 +1,162 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { FleetError } from '../application/errors.js';
5
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
6
+ export const MAX_ATTACHMENT_BODY = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 4096;
7
+ function fail(message) { throw new FleetError('invalid_request', message); }
8
+ const digest = (data) => createHash('sha256').update(data).digest('hex');
9
+ function directory(path, create = false, privateStorage = true) {
10
+ if (create && !existsSync(path))
11
+ mkdirSync(path, { mode: 0o700 });
12
+ const stat = lstatSync(path);
13
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & (privateStorage ? 0o022 : 0o002)))
14
+ fail('Attachment directory is not a private owned directory');
15
+ }
16
+ function read(path, max) {
17
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
18
+ try {
19
+ const stat = fstatSync(fd);
20
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.size > max)
21
+ fail('Attachment is not a bounded owned regular file');
22
+ return readFileSync(fd);
23
+ }
24
+ finally {
25
+ closeSync(fd);
26
+ }
27
+ }
28
+ function immutable(path, bytes) {
29
+ try {
30
+ writeFileSync(path, bytes, { flag: 'wx', mode: 0o400 });
31
+ }
32
+ catch (error) {
33
+ if (error.code !== 'EEXIST')
34
+ throw error;
35
+ if (!read(path, bytes.length).equals(bytes))
36
+ fail('Attachment content changed');
37
+ }
38
+ }
39
+ function filename(name) {
40
+ const chars = Array.from(name.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_'));
41
+ while (Buffer.byteLength(chars.join('')) > 120)
42
+ chars.pop();
43
+ return chars.join('') || 'file';
44
+ }
45
+ export function storeAgentAttachment(stateDir, generation, input) {
46
+ if (!input || typeof input !== 'object')
47
+ fail('File data is required');
48
+ const body = input;
49
+ if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 240 || /[\x00-\x1f]/u.test(body.name))
50
+ fail('A file name of 1–240 characters is required');
51
+ if (typeof body.mimeType !== 'string' || !/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(body.mimeType))
52
+ fail('A valid media type is required');
53
+ if (typeof body.data !== 'string' || body.data.length > Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 || /[^A-Za-z0-9+/=]/.test(body.data))
54
+ fail('Invalid base64 file data or file exceeds 20 MiB');
55
+ const bytes = Buffer.from(body.data, 'base64');
56
+ if (bytes.length > MAX_ATTACHMENT_BYTES || bytes.toString('base64') !== body.data)
57
+ fail('Invalid file data or file exceeds 20 MiB');
58
+ const sha256 = digest(bytes), name = body.name, mimeType = body.mimeType;
59
+ const id = digest(JSON.stringify({ generation, name, mimeType, sha256 }));
60
+ const record = { id, name, mimeType, size: bytes.length, sha256, generation };
61
+ // Fleet runtime state directories may use the account group (0775). Upload storage remains private.
62
+ directory(stateDir, false, false);
63
+ const root = join(stateDir, 'web-attachments');
64
+ directory(root, true);
65
+ const dir = join(root, id);
66
+ directory(dir, true);
67
+ immutable(join(dir, 'content-' + filename(name)), bytes);
68
+ immutable(join(dir, 'metadata.json'), Buffer.from(JSON.stringify(record)));
69
+ return record;
70
+ }
71
+ export function attachmentPrompt(stateDir, generation, ids, text) {
72
+ if (!Array.isArray(ids) || ids.length < 1 || ids.length > 5 || new Set(ids).size !== ids.length)
73
+ fail('Select between 1 and 5 distinct attachments');
74
+ // Fleet runtime state directories may use the account group (0775). Upload storage remains private.
75
+ directory(stateDir, false, false);
76
+ const root = join(stateDir, 'web-attachments');
77
+ directory(root);
78
+ const files = ids.map(id => {
79
+ if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id))
80
+ fail('Invalid attachment ID');
81
+ const dir = join(root, id);
82
+ directory(dir);
83
+ const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
84
+ if (record.id !== id || record.generation !== generation)
85
+ throw new FleetError('stale_state', 'Attachment belongs to a different agent session');
86
+ const file = join(dir, 'content-' + filename(record.name)), bytes = read(file, MAX_ATTACHMENT_BYTES);
87
+ if (bytes.length !== record.size || digest(bytes) !== record.sha256 || digest(JSON.stringify({ generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
88
+ fail('Attachment content changed');
89
+ return { name: record.name, mimeType: record.mimeType, size: record.size, sha256: record.sha256, path: file };
90
+ });
91
+ return `${text}${text ? '\n\n' : ''}Attached files (available on this host; open the files to inspect their contents, and use the image viewer for images):\n${JSON.stringify(files, null, 2)}`;
92
+ }
93
+ /** Authenticated content access reuses the same ownership and integrity checks as admission. */
94
+ function attachmentMetadata(stateDir, id) {
95
+ if (!/^[a-f0-9]{64}$/.test(id))
96
+ fail('Invalid attachment ID');
97
+ directory(stateDir, false, false);
98
+ const root = join(stateDir, 'web-attachments');
99
+ directory(root);
100
+ const dir = join(root, id);
101
+ directory(dir);
102
+ const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
103
+ if (record.id !== id || typeof record.name !== 'string' || typeof record.mimeType !== 'string')
104
+ fail('Invalid attachment metadata');
105
+ if (digest(JSON.stringify({ generation: record.generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
106
+ fail('Attachment metadata changed');
107
+ return record;
108
+ }
109
+ export function readAgentAttachment(stateDir, id) {
110
+ let record;
111
+ try {
112
+ record = attachmentMetadata(stateDir, id);
113
+ }
114
+ catch (error) {
115
+ if (error.code === 'ENOENT')
116
+ throw new FleetError('resource_not_found', 'Attachment not found');
117
+ throw error;
118
+ }
119
+ const bytes = read(join(stateDir, 'web-attachments', id, 'content-' + filename(record.name)), MAX_ATTACHMENT_BYTES);
120
+ if (bytes.length !== record.size || digest(bytes) !== record.sha256)
121
+ fail('Attachment content changed');
122
+ return { record, bytes };
123
+ }
124
+ /** Written before admission so a lost HTTP response cannot lose the visible message. */
125
+ export function storeAttachmentPresentation(stateDir, value) {
126
+ directory(stateDir, false, false);
127
+ const root = join(stateDir, 'web-attachment-messages');
128
+ directory(root, true);
129
+ immutable(join(root, digest(JSON.stringify([value.generation, value.commandId])) + '.json'), Buffer.from(JSON.stringify(value)));
130
+ }
131
+ export function prepareAttachmentPresentation(stateDir, generation, commandId, ids, text, transport) {
132
+ storeAttachmentPresentation(stateDir, { generation, commandId, ids, text, transportDigest: digest(transport) });
133
+ }
134
+ /** Never infer attachments by stripping user text or interpreting a marker. */
135
+ export function presentAttachmentEvent(stateDir, roleId, event) {
136
+ const e = event;
137
+ if (!stateDir || e.kind !== 'prompt.admitted' || e.source !== 'owner_admin_console' || !e.commandId || e.payload?.text?.redacted)
138
+ return event;
139
+ const root = join(stateDir, 'web-attachment-messages');
140
+ if (!existsSync(root))
141
+ return event;
142
+ const path = join(root, digest(JSON.stringify([e.sessionGeneration, e.commandId])) + '.json');
143
+ if (!existsSync(path))
144
+ return event;
145
+ try {
146
+ directory(stateDir, false, false);
147
+ directory(root);
148
+ const value = JSON.parse(read(path, 1024 * 1024).toString());
149
+ if (value.generation !== e.sessionGeneration || value.commandId !== e.commandId || value.transportDigest !== digest(e.payload.text.text))
150
+ return event;
151
+ const attachments = value.ids.map(id => {
152
+ const record = attachmentMetadata(stateDir, id);
153
+ if (record.generation !== value.generation)
154
+ fail('Attachment session changed');
155
+ return { id, name: record.name, mimeType: record.mimeType, size: record.size, url: `/api/v1/roles/${encodeURIComponent(roleId)}/attachments/${id}` };
156
+ });
157
+ return { ...e, payload: { ...e.payload, text: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, displayText: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, attachments } };
158
+ }
159
+ catch {
160
+ return event;
161
+ }
162
+ }
@@ -33,6 +33,7 @@ export declare class WebAuth {
33
33
  private readonly sessionDevices;
34
34
  private readonly tickets;
35
35
  private readonly rates;
36
+ private readonly transports;
36
37
  private readonly sockets;
37
38
  constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
38
39
  get bootstrapSecret(): string;
@@ -48,7 +49,7 @@ export declare class WebAuth {
48
49
  }): void;
49
50
  /** Mint a replacement for an operator-triggered reauthentication ceremony. */
50
51
  mintBootstrap(): string;
51
- validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
52
+ validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
52
53
  exchange(request: FastifyRequest): AuthResult;
53
54
  login(request: FastifyRequest, password: string): AuthResult;
54
55
  anonymous(request: FastifyRequest): BrowserSession;
@@ -61,6 +62,8 @@ export declare class WebAuth {
61
62
  };
62
63
  consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
63
64
  bindSocket(sessionId: string, socket: WebSocket): void;
65
+ /** Raw service streams share the same logout/revocation boundary as native sockets. */
66
+ bindTransport(sessionId: string, close: () => void): () => void;
64
67
  clearSessions(): void;
65
68
  revokeAllTrustedDevices(): number;
66
69
  shutdown(): void;
package/dist/web/auth.js CHANGED
@@ -21,6 +21,7 @@ export class WebAuth {
21
21
  sessionDevices = new Map();
22
22
  tickets = new Map();
23
23
  rates = new Map();
24
+ transports = new Map();
24
25
  sockets = new Map();
25
26
  constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
26
27
  this._origin = _origin;
@@ -49,7 +50,7 @@ export class WebAuth {
49
50
  this.bootstrapUsed = false;
50
51
  return this._bootstrapSecret;
51
52
  }
52
- validateBoundary(request, requireOrigin) {
53
+ validateBoundary(request, requireOrigin, allowPageNavigation = false) {
53
54
  const host = request.headers.host;
54
55
  const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
55
56
  const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
@@ -58,7 +59,9 @@ export class WebAuth {
58
59
  if (requireOrigin && (!request.headers.origin || !origins.has(request.headers.origin)))
59
60
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
60
61
  const fetchSite = request.headers['sec-fetch-site'];
61
- if (fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
62
+ const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
63
+ && request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document';
64
+ if (!pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
62
65
  throw new FleetError('forbidden', 'cross-site request rejected');
63
66
  }
64
67
  exchange(request) {
@@ -156,7 +159,22 @@ export class WebAuth {
156
159
  sockets.add(socket);
157
160
  socket.once('close', () => sockets?.delete(socket));
158
161
  }
162
+ /** Raw service streams share the same logout/revocation boundary as native sockets. */
163
+ bindTransport(sessionId, close) {
164
+ let transports = this.transports.get(sessionId);
165
+ if (!transports) {
166
+ transports = new Set();
167
+ this.transports.set(sessionId, transports);
168
+ }
169
+ transports.add(close);
170
+ return () => { transports.delete(close); if (!transports.size)
171
+ this.transports.delete(sessionId); };
172
+ }
159
173
  clearSessions() {
174
+ for (const transports of this.transports.values())
175
+ for (const close of transports)
176
+ close();
177
+ this.transports.clear();
160
178
  for (const sockets of this.sockets.values())
161
179
  for (const socket of sockets)
162
180
  socket.close(4401, 'authentication revoked');
@@ -182,6 +200,9 @@ export class WebAuth {
182
200
  return session;
183
201
  }
184
202
  removeSession(id) {
203
+ for (const close of this.transports.get(id) ?? [])
204
+ close();
205
+ this.transports.delete(id);
185
206
  this.sessions.delete(id);
186
207
  this.sessionDevices.delete(id);
187
208
  for (const [ticket, value] of this.tickets)
@@ -1,9 +1,10 @@
1
+ import { socketPath as privateSocketPath } from '../socket-path.js';
1
2
  import { chmodSync, mkdirSync, rmSync } from 'node:fs';
2
3
  import { createConnection, createServer } from 'node:net';
3
4
  import { join } from 'node:path';
4
5
  import { FleetError, safeLine } from '../application/errors.js';
5
6
  import { stateRoot } from '../paths.js';
6
- export const webControlPath = (dir = join(stateRoot(), 'web')) => join(dir, 'control.sock');
7
+ export const webControlPath = (dir = join(stateRoot(), 'web')) => privateSocketPath(join(dir, 'control.sock'));
7
8
  export async function startWebControlServer(options) {
8
9
  const dir = options.dir ?? join(stateRoot(), 'web');
9
10
  const path = webControlPath(dir);