@ours.network/fleet 1.2.0-nightly.3 → 1.2.0-nightly.30

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 (312) hide show
  1. package/README.md +158 -28
  2. package/dist/account-origin.d.ts +2 -0
  3. package/dist/account-origin.js +6 -0
  4. package/dist/agent-ours/bridge.d.ts +3 -0
  5. package/dist/agent-ours/bridge.js +178 -0
  6. package/dist/agent-ours/controller.d.ts +14 -0
  7. package/dist/agent-ours/controller.js +60 -0
  8. package/dist/agent-ours/correspondence.d.ts +10 -0
  9. package/dist/agent-ours/correspondence.js +1 -0
  10. package/dist/agent-ours/file-rpc.d.ts +23 -0
  11. package/dist/agent-ours/file-rpc.js +123 -0
  12. package/dist/agent-ours/harness.d.ts +14 -0
  13. package/dist/agent-ours/harness.js +72 -0
  14. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  15. package/dist/agent-ours/mcp-endpoint.js +111 -0
  16. package/dist/agent-ours/runtime.d.ts +59 -0
  17. package/dist/agent-ours/runtime.js +283 -0
  18. package/dist/agent-ours/service.d.ts +23 -0
  19. package/dist/agent-ours/service.js +372 -0
  20. package/dist/agent-ours/state.d.ts +45 -0
  21. package/dist/agent-ours/state.js +95 -0
  22. package/dist/agent-ours/wire.d.ts +14 -0
  23. package/dist/agent-ours/wire.js +55 -0
  24. package/dist/application/conversation-history.d.ts +25 -0
  25. package/dist/application/conversation-history.js +189 -0
  26. package/dist/application/fleet-query-service.d.ts +1 -1
  27. package/dist/application/fleet-query-service.js +2 -2
  28. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  29. package/dist/application/legacy-supervisor-identity.js +43 -0
  30. package/dist/application/model-catalog.d.ts +1 -1
  31. package/dist/application/model-catalog.js +5 -2
  32. package/dist/application/preset-provenance.d.ts +19 -0
  33. package/dist/application/preset-provenance.js +59 -0
  34. package/dist/application/room-layout-definitions.d.ts +45 -0
  35. package/dist/application/room-layout-definitions.js +130 -0
  36. package/dist/application/session-control.d.ts +18 -0
  37. package/dist/application/session-control.js +54 -3
  38. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  39. package/dist/application/supervisor-ours-tools.js +113 -0
  40. package/dist/application/task-layouts.d.ts +91 -0
  41. package/dist/application/task-layouts.js +249 -0
  42. package/dist/application/task-room-service.d.ts +99 -3
  43. package/dist/application/task-room-service.js +296 -31
  44. package/dist/atomic-file.d.ts +5 -0
  45. package/dist/atomic-file.js +62 -1
  46. package/dist/briefing.js +10 -59
  47. package/dist/build-info.json +6 -4
  48. package/dist/capabilities.d.ts +3 -1
  49. package/dist/capabilities.js +4 -0
  50. package/dist/cli.js +109 -3
  51. package/dist/client-profile.d.ts +5 -16
  52. package/dist/client-profile.js +4 -112
  53. package/dist/config.d.ts +4 -0
  54. package/dist/config.js +7 -0
  55. package/dist/creation.d.ts +1 -1
  56. package/dist/creation.js +5 -16
  57. package/dist/daemon-recovery.d.ts +1 -8
  58. package/dist/daemon-recovery.js +8 -113
  59. package/dist/docs.d.ts +3 -3
  60. package/dist/docs.js +43 -23
  61. package/dist/doctor.d.ts +1 -1
  62. package/dist/doctor.js +46 -59
  63. package/dist/erased-resources.d.ts +9 -0
  64. package/dist/erased-resources.js +87 -0
  65. package/dist/fleet-command-audit.d.ts +1 -0
  66. package/dist/fleet-command-audit.js +12 -7
  67. package/dist/harness/agent-session.d.ts +2 -0
  68. package/dist/harness/claude-code-session.js +4 -2
  69. package/dist/harness/codex-app-server-proxy.d.ts +4 -1
  70. package/dist/harness/codex-app-server-proxy.js +41 -5
  71. package/dist/harness/codex-session.js +9 -2
  72. package/dist/harness/codex.d.ts +3 -2
  73. package/dist/harness/codex.js +4 -3
  74. package/dist/harness/hermes-session.js +7 -1
  75. package/dist/init-wizard.js +20 -9
  76. package/dist/isolation/policy.js +2 -0
  77. package/dist/isolation/types.d.ts +5 -0
  78. package/dist/monitor.d.ts +1 -34
  79. package/dist/monitor.js +24 -151
  80. package/dist/notifications/fleet-producer.d.ts +18 -0
  81. package/dist/notifications/fleet-producer.js +129 -0
  82. package/dist/notifications/outbox.d.ts +31 -0
  83. package/dist/notifications/outbox.js +104 -0
  84. package/dist/owner-channel/channel.d.ts +3 -0
  85. package/dist/owner-channel/channel.js +34 -6
  86. package/dist/owner-channel/commands.d.ts +4 -0
  87. package/dist/owner-channel/commands.js +8 -0
  88. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  89. package/dist/owner-channel/ours-client.js +2 -17
  90. package/dist/preset-migration.js +15 -0
  91. package/dist/rooms-tasks/add-member.d.ts +48 -0
  92. package/dist/rooms-tasks/add-member.js +83 -0
  93. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  94. package/dist/rooms-tasks/archived-absence.js +48 -0
  95. package/dist/rooms-tasks/cli.js +34 -5
  96. package/dist/rooms-tasks/close.d.ts +5 -2
  97. package/dist/rooms-tasks/close.js +208 -28
  98. package/dist/rooms-tasks/config.js +3 -1
  99. package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
  100. package/dist/rooms-tasks/cowork-adapter.js +11 -117
  101. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  102. package/dist/rooms-tasks/cowork-http.js +70 -0
  103. package/dist/rooms-tasks/deletion.d.ts +1 -1
  104. package/dist/rooms-tasks/deletion.js +74 -5
  105. package/dist/rooms-tasks/erasure.d.ts +6 -0
  106. package/dist/rooms-tasks/erasure.js +169 -0
  107. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  108. package/dist/rooms-tasks/external-worker.js +3 -4
  109. package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
  110. package/dist/rooms-tasks/layout-binding-client.js +56 -0
  111. package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
  112. package/dist/rooms-tasks/layout-binding-grants.js +128 -0
  113. package/dist/rooms-tasks/layout-cli.d.ts +4 -0
  114. package/dist/rooms-tasks/layout-cli.js +107 -0
  115. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  116. package/dist/rooms-tasks/layout-config.js +89 -0
  117. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  118. package/dist/rooms-tasks/layout-control.js +52 -0
  119. package/dist/rooms-tasks/layout-service.d.ts +42 -0
  120. package/dist/rooms-tasks/layout-service.js +185 -0
  121. package/dist/rooms-tasks/layout.d.ts +114 -0
  122. package/dist/rooms-tasks/layout.js +250 -0
  123. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  124. package/dist/rooms-tasks/live-readiness.js +85 -0
  125. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  126. package/dist/rooms-tasks/member-startup.js +2 -0
  127. package/dist/rooms-tasks/provision.d.ts +14 -0
  128. package/dist/rooms-tasks/provision.js +182 -48
  129. package/dist/rooms-tasks/room-state.d.ts +4 -2
  130. package/dist/rooms-tasks/room-state.js +53 -8
  131. package/dist/rooms-tasks/task-state.d.ts +42 -7
  132. package/dist/rooms-tasks/task-state.js +153 -23
  133. package/dist/rooms-tasks/types.d.ts +28 -0
  134. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  135. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  136. package/dist/rooms-tasks/workspace.d.ts +26 -0
  137. package/dist/rooms-tasks/workspace.js +195 -0
  138. package/dist/runner.d.ts +23 -3
  139. package/dist/runner.js +581 -494
  140. package/dist/session/acp.d.ts +3 -0
  141. package/dist/session/acp.js +14 -0
  142. package/dist/session/arbiter.d.ts +3 -0
  143. package/dist/session/arbiter.js +20 -2
  144. package/dist/session/codex-app-server-transport.js +1 -1
  145. package/dist/session/codex-app-server.d.ts +3 -0
  146. package/dist/session/codex-app-server.js +25 -1
  147. package/dist/session/control.d.ts +10 -1
  148. package/dist/session/control.js +37 -2
  149. package/dist/session/conversation-normalizer.d.ts +2 -1
  150. package/dist/session/conversation-normalizer.js +1 -1
  151. package/dist/session/conversation-types.d.ts +5 -3
  152. package/dist/session/task-notice.d.ts +14 -0
  153. package/dist/session/task-notice.js +13 -0
  154. package/dist/session/types.d.ts +3 -0
  155. package/dist/socket-path.d.ts +2 -0
  156. package/dist/socket-path.js +22 -0
  157. package/dist/spawn.js +19 -16
  158. package/dist/startup-readiness.d.ts +13 -0
  159. package/dist/startup-readiness.js +38 -0
  160. package/dist/subscriptions/cli.d.ts +78 -0
  161. package/dist/subscriptions/cli.js +186 -0
  162. package/dist/subscriptions/launch.d.ts +37 -0
  163. package/dist/subscriptions/launch.js +93 -0
  164. package/dist/subscriptions/login.d.ts +75 -0
  165. package/dist/subscriptions/login.js +182 -0
  166. package/dist/subscriptions/observed.d.ts +7 -0
  167. package/dist/subscriptions/observed.js +44 -0
  168. package/dist/subscriptions/probe.d.ts +10 -0
  169. package/dist/subscriptions/probe.js +73 -0
  170. package/dist/subscriptions/service.d.ts +102 -0
  171. package/dist/subscriptions/service.js +364 -0
  172. package/dist/subscriptions/store.d.ts +71 -0
  173. package/dist/subscriptions/store.js +140 -0
  174. package/dist/supervisor/launchd.js +2 -1
  175. package/dist/supervisor/systemd.js +9 -23
  176. package/dist/temp-idle.d.ts +17 -0
  177. package/dist/temp-idle.js +42 -0
  178. package/dist/temp-lifecycle.d.ts +6 -1
  179. package/dist/temp-lifecycle.js +29 -13
  180. package/dist/temp-supervisor-recovery.d.ts +3 -0
  181. package/dist/temp-supervisor-recovery.js +46 -0
  182. package/dist/watchdog/briefing.js +3 -16
  183. package/dist/watchdog/run.js +19 -5
  184. package/dist/web/agent-attachments.d.ts +29 -0
  185. package/dist/web/agent-attachments.js +162 -0
  186. package/dist/web/auth.d.ts +28 -2
  187. package/dist/web/auth.js +81 -8
  188. package/dist/web/control.d.ts +3 -2
  189. package/dist/web/control.js +11 -7
  190. package/dist/web/fleet-config-service.d.ts +4 -1
  191. package/dist/web/fleet-config-service.js +46 -15
  192. package/dist/web/prefix-gateway.d.ts +23 -0
  193. package/dist/web/prefix-gateway.js +156 -0
  194. package/dist/web/runtime.d.ts +4 -0
  195. package/dist/web/runtime.js +90 -19
  196. package/dist/web/server.d.ts +11 -0
  197. package/dist/web/server.js +445 -31
  198. package/dist/web/service.d.ts +6 -2
  199. package/dist/web/service.js +22 -4
  200. package/dist/web/workspace-devices.d.ts +29 -0
  201. package/dist/web/workspace-devices.js +84 -0
  202. package/dist/web/workspace-tunnel.d.ts +1 -0
  203. package/dist/web/workspace-tunnel.js +32 -0
  204. package/dist/workspace-enrollment.d.ts +25 -0
  205. package/dist/workspace-enrollment.js +112 -0
  206. package/docs/room-layouts.md +227 -0
  207. package/docs/task-workspaces.md +130 -0
  208. package/examples/room-layouts/fresh.yaml +27 -0
  209. package/examples/room-layouts/scoped.yaml +21 -0
  210. package/examples/room-layouts/shared.yaml +19 -0
  211. package/package.json +14 -17
  212. package/presets/README.md +78 -0
  213. package/presets/brain-catalog.json +8 -2
  214. package/presets/fleet/agent_templates/Engineer.yaml +5 -0
  215. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  216. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  217. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  218. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  219. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  220. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  221. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  222. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  223. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  224. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  225. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  226. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  227. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  228. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  229. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  230. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  231. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  232. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  233. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  234. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  235. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  236. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  237. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  238. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  239. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  240. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  241. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  242. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  243. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  244. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  245. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  246. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  247. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  248. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  249. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  250. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  251. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  252. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  253. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  254. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  255. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  256. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  257. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  258. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  259. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  260. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  261. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  262. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  263. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  264. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  265. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  266. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  267. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  268. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  269. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  270. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  271. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  272. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  273. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  274. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
  275. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
  276. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
  277. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
  278. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
  279. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
  280. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  281. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  282. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  283. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  284. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  285. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  286. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  287. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  288. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  289. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  290. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  291. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  292. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  293. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  294. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  295. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  296. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  297. package/presets/fleet/roles/Coordinator.yaml +22 -0
  298. package/presets/fleet/roles/Engineer.yaml +40 -0
  299. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  300. package/presets/fleet/room_layouts/pair.yaml +24 -0
  301. package/presets/fleet/room_layouts/single.yaml +17 -0
  302. package/presets/fleet/room_layouts/team.yaml +30 -0
  303. package/presets/fleet/room_templates/engineering.yaml +19 -0
  304. package/presets/manifest.json +2 -2
  305. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  306. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  307. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  308. package/dist/web-app/icons/ours-fleet.svg +0 -4
  309. package/dist/web-app/index.html +0 -17
  310. package/dist/web-app/manifest.webmanifest +0 -15
  311. package/dist/web-app/offline.html +0 -18
  312. package/dist/web-app/sw.js +0 -51
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { WorkspaceDeviceStore } from './workspace-devices.js';
1
2
  import type { FastifyRequest } from 'fastify';
2
3
  import type { WebSocket } from 'ws';
3
4
  import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
@@ -26,6 +27,8 @@ export declare class WebAuth {
26
27
  private readonly now;
27
28
  private readonly devices;
28
29
  private readonly access;
30
+ private readonly workspaceDevices?;
31
+ readonly appOrigin: string;
29
32
  private _bootstrapSecret;
30
33
  private bootstrapExpiresAt;
31
34
  private bootstrapUsed;
@@ -33,8 +36,9 @@ export declare class WebAuth {
33
36
  private readonly sessionDevices;
34
37
  private readonly tickets;
35
38
  private readonly rates;
39
+ private readonly transports;
36
40
  private readonly sockets;
37
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
41
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
38
42
  get bootstrapSecret(): string;
39
43
  get origin(): string;
40
44
  get host(): string;
@@ -48,7 +52,7 @@ export declare class WebAuth {
48
52
  }): void;
49
53
  /** Mint a replacement for an operator-triggered reauthentication ceremony. */
50
54
  mintBootstrap(): string;
51
- validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
55
+ validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
52
56
  exchange(request: FastifyRequest): AuthResult;
53
57
  login(request: FastifyRequest, password: string): AuthResult;
54
58
  anonymous(request: FastifyRequest): BrowserSession;
@@ -61,8 +65,30 @@ export declare class WebAuth {
61
65
  };
62
66
  consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
63
67
  bindSocket(sessionId: string, socket: WebSocket): void;
68
+ /** Raw service streams share the same logout/revocation boundary as native sockets. */
69
+ bindTransport(sessionId: string, close: () => void): () => void;
64
70
  clearSessions(): void;
65
71
  revokeAllTrustedDevices(): number;
72
+ workspaceIdentity(): {
73
+ workspaceId: string;
74
+ };
75
+ workspaceEnrollment(issuer?: string | null): {
76
+ enrollment: string;
77
+ workspaceId: string;
78
+ expiresAt: number;
79
+ };
80
+ enrollWorkspace(request: FastifyRequest, input: {
81
+ enrollment: string;
82
+ workspaceId: string;
83
+ label: string;
84
+ }): {
85
+ token: string;
86
+ device: import("./workspace-devices.js").WorkspaceDevice;
87
+ workspaceId: string;
88
+ };
89
+ listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
90
+ revokeWorkspaceDevice(id: string): void;
91
+ private requireWorkspaceDevices;
66
92
  shutdown(): void;
67
93
  private createSession;
68
94
  private removeSession;
package/dist/web/auth.js CHANGED
@@ -14,6 +14,8 @@ export class WebAuth {
14
14
  now;
15
15
  devices;
16
16
  access;
17
+ workspaceDevices;
18
+ appOrigin;
17
19
  _bootstrapSecret = token(32);
18
20
  bootstrapExpiresAt = Date.now() + 5 * 60_000;
19
21
  bootstrapUsed = false;
@@ -21,13 +23,16 @@ export class WebAuth {
21
23
  sessionDevices = new Map();
22
24
  tickets = new Map();
23
25
  rates = new Map();
26
+ transports = new Map();
24
27
  sockets = new Map();
25
- constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
28
+ constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
26
29
  this._origin = _origin;
27
30
  this._host = _host;
28
31
  this.now = now;
29
32
  this.devices = devices;
30
33
  this.access = access;
34
+ this.workspaceDevices = workspaceDevices;
35
+ this.appOrigin = appOrigin;
31
36
  }
32
37
  get bootstrapSecret() { return this._bootstrapSecret; }
33
38
  get origin() { return this._origin; }
@@ -49,16 +54,23 @@ export class WebAuth {
49
54
  this.bootstrapUsed = false;
50
55
  return this._bootstrapSecret;
51
56
  }
52
- validateBoundary(request, requireOrigin) {
57
+ validateBoundary(request, requireOrigin, allowPageNavigation = false) {
53
58
  const host = request.headers.host;
54
59
  const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
55
60
  const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
56
61
  if (!host || !hosts.has(host))
57
62
  throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
58
- if (requireOrigin && (!request.headers.origin || !origins.has(request.headers.origin)))
63
+ if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
64
+ throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
65
+ const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
66
+ && (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
67
+ if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
59
68
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
60
69
  const fetchSite = request.headers['sec-fetch-site'];
61
- if (fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
70
+ const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
71
+ && request.headers['sec-fetch-mode'] === 'navigate'
72
+ && (request.headers['sec-fetch-dest'] === 'document' || (Boolean(this.workspaceDevices) && request.headers['sec-fetch-dest'] === 'iframe' && new URL(request.url || '/', this.origin).searchParams.get('workspace-frame') === '1'));
73
+ if (!workspaceRequest && !pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
62
74
  throw new FleetError('forbidden', 'cross-site request rejected');
63
75
  }
64
76
  exchange(request) {
@@ -100,7 +112,23 @@ export class WebAuth {
100
112
  return { session: this.createSession(device.id), device };
101
113
  }
102
114
  authenticate(request, mutation = false) {
115
+ const authorization = String(request.headers.authorization ?? '');
116
+ if (authorization.startsWith('Bearer ')) {
117
+ this.validateBoundary(request, mutation);
118
+ if (!this.workspaceDevices)
119
+ throw new FleetError('unauthorized', 'workspace bearer credential is invalid');
120
+ const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
121
+ const id = `workspace:${device.id}`;
122
+ const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.expiresAt };
123
+ this.sessions.set(id, session);
124
+ this.sessionDevices.set(id, id);
125
+ if (mutation)
126
+ this.consumeRate(`mutation:${id}`, 120, 60000);
127
+ return session;
128
+ }
103
129
  this.validateBoundary(request, mutation);
130
+ if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
131
+ throw new FleetError('unauthorized', 'workspace bearer credential required');
104
132
  const id = parseCookies(request.headers.cookie ?? '').ofs_session;
105
133
  const session = id ? this.sessions.get(id) : undefined;
106
134
  const now = this.now();
@@ -121,7 +149,9 @@ export class WebAuth {
121
149
  logout(request) {
122
150
  const session = this.authenticate(request, true);
123
151
  const deviceId = this.sessionDevices.get(session.id);
124
- if (deviceId && deviceId !== 'unprotected')
152
+ if (session.id.startsWith('workspace:'))
153
+ this.workspaceDevices?.revoke(session.id.slice(10));
154
+ else if (deviceId && deviceId !== 'unprotected')
125
155
  this.devices.revokeId(deviceId);
126
156
  this.removeSession(session.id);
127
157
  }
@@ -143,7 +173,7 @@ export class WebAuth {
143
173
  || ticket.roleId !== roleId)
144
174
  throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
145
175
  const session = this.sessions.get(ticket.sessionId);
146
- if (!session)
176
+ if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
147
177
  throw new FleetError('unauthorized', 'browser session expired');
148
178
  return session;
149
179
  }
@@ -154,9 +184,30 @@ export class WebAuth {
154
184
  this.sockets.set(sessionId, sockets);
155
185
  }
156
186
  sockets.add(socket);
157
- socket.once('close', () => sockets?.delete(socket));
187
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
188
+ this.removeSession(sessionId); }, 1000);
189
+ expiry.unref();
190
+ socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
191
+ }
192
+ /** Raw service streams share the same logout/revocation boundary as native sockets. */
193
+ bindTransport(sessionId, close) {
194
+ let transports = this.transports.get(sessionId);
195
+ if (!transports) {
196
+ transports = new Set();
197
+ this.transports.set(sessionId, transports);
198
+ }
199
+ transports.add(close);
200
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
201
+ this.removeSession(sessionId); }, 1000);
202
+ expiry.unref();
203
+ return () => { clearInterval(expiry); transports.delete(close); if (!transports.size)
204
+ this.transports.delete(sessionId); };
158
205
  }
159
206
  clearSessions() {
207
+ for (const transports of this.transports.values())
208
+ for (const close of transports)
209
+ close();
210
+ this.transports.clear();
160
211
  for (const sockets of this.sockets.values())
161
212
  for (const socket of sockets)
162
213
  socket.close(4401, 'authentication revoked');
@@ -167,10 +218,29 @@ export class WebAuth {
167
218
  }
168
219
  revokeAllTrustedDevices() {
169
220
  const count = this.devices.revokeAll();
221
+ for (const device of this.workspaceDevices?.list() ?? [])
222
+ this.workspaceDevices?.revoke(device.id);
170
223
  this.clearSessions();
171
224
  return count;
172
225
  }
173
- shutdown() { this.clearSessions(); }
226
+ workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
227
+ workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
228
+ enrollWorkspace(request, input) {
229
+ this.validateBoundary(request, true);
230
+ this.consumeRate('workspace-enrollment', 30, 60000);
231
+ return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
232
+ }
233
+ listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
234
+ revokeWorkspaceDevice(id) {
235
+ this.requireWorkspaceDevices().revoke(id);
236
+ this.removeSession(`workspace:${id}`);
237
+ }
238
+ requireWorkspaceDevices() {
239
+ if (!this.workspaceDevices)
240
+ throw new FleetError('capability_unavailable', 'workspace device access is not configured');
241
+ return this.workspaceDevices;
242
+ }
243
+ shutdown() { this.clearSessions(); this.workspaceDevices?.close(); }
174
244
  createSession(deviceId) {
175
245
  const now = this.now();
176
246
  const session = {
@@ -182,6 +252,9 @@ export class WebAuth {
182
252
  return session;
183
253
  }
184
254
  removeSession(id) {
255
+ for (const close of this.transports.get(id) ?? [])
256
+ close();
257
+ this.transports.delete(id);
185
258
  this.sessions.delete(id);
186
259
  this.sessionDevices.delete(id);
187
260
  for (const [ticket, value] of this.tickets)
@@ -1,4 +1,4 @@
1
- export type WebControlCommand = 'open' | 'revoke-all';
1
+ export type WebControlCommand = 'open' | 'revoke-all' | 'link-device';
2
2
  export declare const webControlPath: (dir?: string) => string;
3
3
  export interface WebControlServer {
4
4
  path: string;
@@ -7,8 +7,9 @@ export interface WebControlServer {
7
7
  export declare function startWebControlServer(options: {
8
8
  dir?: string;
9
9
  onOpen(): void | Promise<void>;
10
+ onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
10
11
  onRevokeAll(): void | Promise<void>;
11
12
  now?: () => number;
12
13
  rateLimit?: number;
13
14
  }): Promise<WebControlServer>;
14
- export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
15
+ export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<Record<string, unknown> | void>;
@@ -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);
@@ -34,13 +35,16 @@ export async function startWebControlServer(options) {
34
35
  if (used > rateLimit)
35
36
  throw new FleetError('rate_limited', 'local web control rate limit exceeded');
36
37
  const parsed = JSON.parse(body.slice(0, body.indexOf('\n')));
37
- if (parsed.command === 'open')
38
+ let result;
39
+ if (parsed.command === 'link-device' && options.onLinkDevice)
40
+ result = await options.onLinkDevice();
41
+ else if (parsed.command === 'open')
38
42
  await options.onOpen();
39
43
  else if (parsed.command === 'revoke-all')
40
44
  await options.onRevokeAll();
41
45
  else
42
46
  throw new FleetError('invalid_request', 'unknown local web control command');
43
- socket.end(JSON.stringify({ ok: true }) + '\n');
47
+ socket.end(JSON.stringify({ ok: true, result }) + '\n');
44
48
  }
45
49
  catch (error) {
46
50
  const code = error instanceof FleetError ? error.code : 'internal';
@@ -65,7 +69,7 @@ export async function startWebControlServer(options) {
65
69
  };
66
70
  }
67
71
  export async function requestWebControl(command, path = webControlPath(), timeoutMs = 5_000) {
68
- await new Promise((resolve, reject) => {
72
+ return await new Promise((resolve, reject) => {
69
73
  const socket = createConnection(path);
70
74
  let response = '';
71
75
  let settled = false;
@@ -73,7 +77,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
73
77
  socket.destroy();
74
78
  reject(new FleetError('timeout', 'local web control request timed out'));
75
79
  }, timeoutMs);
76
- const finish = (error) => {
80
+ const finish = (error, result) => {
77
81
  if (settled)
78
82
  return;
79
83
  settled = true;
@@ -81,7 +85,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
81
85
  if (error)
82
86
  reject(error);
83
87
  else
84
- resolve();
88
+ resolve(result);
85
89
  };
86
90
  socket.once('connect', () => socket.write(JSON.stringify({ command }) + '\n'));
87
91
  socket.on('data', chunk => { response += chunk.toString('utf8'); });
@@ -91,7 +95,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
91
95
  const parsed = JSON.parse(response);
92
96
  if (!parsed.ok)
93
97
  throw new FleetError(parsed.error?.code === 'rate_limited' ? 'rate_limited' : 'rejected', parsed.error?.message ?? 'local web control request was rejected');
94
- finish();
98
+ finish(undefined, parsed.result);
95
99
  }
96
100
  catch (error) {
97
101
  finish(error);
@@ -4,6 +4,9 @@ export interface EditableFleetModel {
4
4
  manifest: Record<string, unknown>;
5
5
  agents: Record<string, Record<string, unknown>>;
6
6
  agent_templates: Record<string, Record<string, unknown>>;
7
+ roles?: Record<string, Record<string, unknown>>;
8
+ brains?: Record<string, Record<string, unknown>>;
9
+ room_templates?: Record<string, Record<string, unknown>>;
7
10
  }
8
11
  export interface ConfigReadResult {
9
12
  path: string;
@@ -45,7 +48,7 @@ export declare class FleetConfigService {
45
48
  private readonly preflight;
46
49
  private readonly beforeMutation?;
47
50
  constructor(options?: FleetConfigServiceOptions);
48
- read(): ConfigReadResult;
51
+ read(includeDefinitions?: boolean): ConfigReadResult;
49
52
  preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
50
53
  write(baseRevision: string, input: unknown): Promise<ConfigWriteResult>;
51
54
  }
@@ -23,7 +23,7 @@ export class FleetConfigService {
23
23
  this.preflight = options.preflight ?? (async () => emptyReport());
24
24
  this.beforeMutation = options.beforeMutation;
25
25
  }
26
- read() {
26
+ read(includeDefinitions = false) {
27
27
  const sources = readSources(this.path);
28
28
  try {
29
29
  loadConfig(this.path, { yamlMode: 'strict' });
@@ -31,7 +31,7 @@ export class FleetConfigService {
31
31
  catch (error) {
32
32
  throw safeError(error, sourceSensitiveValues(sources));
33
33
  }
34
- const raw = modelFrom(sources);
34
+ const raw = modelFrom(sources, includeDefinitions);
35
35
  const redacted = redactModel(raw);
36
36
  return {
37
37
  path: basename(this.path), exists: true, firstRun: false,
@@ -41,7 +41,7 @@ export class FleetConfigService {
41
41
  async preview(baseRevision, input) {
42
42
  const current = readSources(this.path);
43
43
  assertRevision(baseRevision, current);
44
- const currentModel = modelFrom(current);
44
+ const currentModel = modelFrom(current, !!input && typeof input === 'object' && 'roles' in input);
45
45
  const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
46
46
  const proposal = renderProposal(current, next);
47
47
  const staged = stageProposal(this.path, current, proposal);
@@ -63,7 +63,7 @@ export class FleetConfigService {
63
63
  return withFileLock(`${splitRootFor(this.path)}.web-edit.lock`, async () => {
64
64
  const current = readSources(this.path);
65
65
  assertRevision(baseRevision, current);
66
- const currentModel = modelFrom(current);
66
+ const currentModel = modelFrom(current, !!input && typeof input === 'object' && 'roles' in input);
67
67
  const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
68
68
  const proposal = renderProposal(current, next);
69
69
  const changed = changedDocuments(current, proposal);
@@ -105,6 +105,8 @@ export class FleetConfigService {
105
105
  rmSync(target, { force: true });
106
106
  else {
107
107
  assertWritableTarget(target, current.documents.has(rel));
108
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
109
+ trustedDirectory(dirname(target));
108
110
  replaceFileAtomically(target, contents, 0o600);
109
111
  chmodSync(target, 0o600);
110
112
  }
@@ -184,7 +186,7 @@ function readSources(manifest) {
184
186
  }
185
187
  return { manifest, root, documents };
186
188
  }
187
- function modelFrom(sources) {
189
+ function modelFrom(sources, includeDefinitions = false) {
188
190
  const manifestKey = basename(sources.manifest);
189
191
  const manifest = parseFleetDocument(sources.manifest, sources.documents.get(manifestKey), 'strict').value;
190
192
  const agents = {};
@@ -199,15 +201,24 @@ function modelFrom(sources) {
199
201
  const id = basename(rel, extname(rel));
200
202
  agent_templates[id] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
201
203
  }
202
- return { manifest, agents, agent_templates };
204
+ const model = { manifest, agents, agent_templates };
205
+ if (includeDefinitions)
206
+ for (const kind of ['roles', 'brains', 'room_templates']) {
207
+ model[kind] = {};
208
+ for (const [rel, source] of sources.documents)
209
+ if (rel.startsWith(kind + '/') && ['.yaml', '.yml'].includes(extname(rel))) {
210
+ model[kind][basename(rel, extname(rel))] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
211
+ }
212
+ }
213
+ return model;
203
214
  }
204
215
  function assertModel(value) {
205
216
  if (!value || typeof value !== 'object' || Array.isArray(value))
206
217
  throw new FleetError('invalid_request', 'model must be an object');
207
218
  const input = value;
208
- if (Object.keys(input).sort().join(',') !== 'agent_templates,agents,manifest')
209
- throw new FleetError('invalid_request', 'model allows exactly manifest, agents, and agent_templates');
210
- for (const key of ['manifest', 'agents', 'agent_templates'])
219
+ if (!['agent_templates,agents,manifest', 'agent_templates,agents,brains,manifest,roles,room_templates'].includes(Object.keys(input).sort().join(',')))
220
+ throw new FleetError('invalid_request', 'model requires manifest, agents, agent_templates and optionally all definition collections');
221
+ for (const key of Object.keys(input))
211
222
  if (!input[key] || typeof input[key] !== 'object' || Array.isArray(input[key]))
212
223
  throw new FleetError('invalid_request', `${key} must be an object`);
213
224
  for (const [id, doc] of Object.entries(input.agents)) {
@@ -222,6 +233,14 @@ function assertModel(value) {
222
233
  if (!doc || typeof doc !== 'object' || Array.isArray(doc))
223
234
  throw new FleetError('invalid_request', `Agent Template '${id}' must be an object`);
224
235
  }
236
+ for (const kind of ['roles', 'brains', 'room_templates']) {
237
+ for (const [id, doc] of Object.entries((input[kind] ?? {}))) {
238
+ if (!ROLE_NAME_RE.test(id))
239
+ throw new FleetError('invalid_request', `invalid definition ID '${id}'`);
240
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
241
+ throw new FleetError('invalid_request', `definition '${id}' must be an object`);
242
+ }
243
+ }
225
244
  return structuredClone(input);
226
245
  }
227
246
  function renderProposal(current, model) {
@@ -240,9 +259,21 @@ function renderProposal(current, model) {
240
259
  next.set(oldRel ?? join('agent_templates', `${id}.yaml`), oldRel
241
260
  ? render(current.documents.get(oldRel), doc) : stringify(doc));
242
261
  }
243
- for (const [rel, source] of current.documents)
244
- if (rel.startsWith('roles/') || rel.startsWith('brains/') || rel.startsWith('room_templates/'))
245
- next.set(rel, source);
262
+ for (const kind of ['roles', 'brains', 'room_templates']) {
263
+ if (model[kind] === undefined) {
264
+ for (const [rel, source] of current.documents)
265
+ if (rel.startsWith(kind + '/'))
266
+ next.set(rel, source);
267
+ continue;
268
+ }
269
+ for (const [rel, source] of current.documents)
270
+ if (rel.startsWith(kind + '/') && !['.yaml', '.yml'].includes(extname(rel)))
271
+ next.set(rel, source);
272
+ for (const [id, doc] of Object.entries(model[kind])) {
273
+ const oldRel = [...current.documents.keys()].find(rel => rel.startsWith(kind + '/') && ['.yaml', '.yml'].includes(extname(rel)) && basename(rel, extname(rel)) === id);
274
+ next.set(oldRel ?? join(kind, `${id}.yaml`), oldRel ? render(current.documents.get(oldRel), doc) : stringify(doc));
275
+ }
276
+ }
246
277
  return next;
247
278
  }
248
279
  function render(source, model) {
@@ -395,9 +426,9 @@ function sourceDiff(before, after, label, secrets) {
395
426
  ...left.slice(left.length - tail, left.length - tail + trailing).map(x => ` ${x}`), ''].join('\n');
396
427
  }
397
428
  function proposalDiff(current, proposal) {
398
- const secrets = sensitiveValues(modelFrom(current));
429
+ const secrets = sensitiveValues(modelFrom(current, true));
399
430
  try {
400
- secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal })));
431
+ secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal }, true)));
401
432
  }
402
433
  catch { /* validation reports later */ }
403
434
  return [...new Set([...current.documents.keys(), ...proposal.keys()])].sort().map(rel => sourceDiff(current.documents.get(rel) ?? '', proposal.get(rel) ?? '', rel, secrets)).filter(Boolean).join('\n');
@@ -405,7 +436,7 @@ function proposalDiff(current, proposal) {
405
436
  function splitLines(source) { return source.replace(/\n$/, '').split('\n'); }
406
437
  function restartImpact(before, after) {
407
438
  const roles = [...new Set([...Object.keys(before.agents), ...Object.keys(after.agents)])]
408
- .filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id])).sort();
439
+ .filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id]) || JSON.stringify(before.roles) !== JSON.stringify(after.roles) || JSON.stringify(before.brains) !== JSON.stringify(after.brains)).sort();
409
440
  const watchdogScheduler = JSON.stringify(before.manifest.watchdogs) !== JSON.stringify(after.manifest.watchdogs);
410
441
  const scheduledLoops = JSON.stringify(before.manifest.loops) !== JSON.stringify(after.manifest.loops);
411
442
  const required = roles.length > 0 || watchdogScheduler || scheduledLoops;