@ours.network/fleet 1.2.0-nightly.34 → 1.2.0-nightly.36

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 (187) hide show
  1. package/README.md +28 -49
  2. package/dist/application/codex-models.d.ts +15 -0
  3. package/dist/application/codex-models.js +56 -0
  4. package/dist/build-info.json +4 -4
  5. package/dist/cli.js +11 -41
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.js +1 -1
  8. package/dist/docs.d.ts +1 -1
  9. package/dist/docs.js +10 -15
  10. package/dist/harness/codex-runtime.d.ts +11 -1
  11. package/dist/harness/codex-runtime.js +31 -4
  12. package/dist/harness/codex.js +4 -2
  13. package/dist/init-wizard.js +2 -1
  14. package/dist/minimal-setup.js +2 -1
  15. package/dist/paths.d.ts +2 -0
  16. package/dist/paths.js +2 -0
  17. package/dist/rooms-tasks/config.d.ts +1 -1
  18. package/dist/rooms-tasks/config.js +8 -3
  19. package/dist/subscriptions/cli.d.ts +15 -2
  20. package/dist/subscriptions/cli.js +41 -4
  21. package/dist/web/auth.d.ts +1 -1
  22. package/dist/web/auth.js +2 -5
  23. package/dist/web/control.d.ts +1 -2
  24. package/dist/web/control.js +0 -2
  25. package/dist/web/prefix-gateway.js +2 -7
  26. package/dist/web/runtime.d.ts +0 -2
  27. package/dist/web/runtime.js +3 -18
  28. package/dist/web/server.d.ts +3 -1
  29. package/dist/web/server.js +14 -35
  30. package/dist/workspace-enrollment.d.ts +17 -0
  31. package/dist/workspace-enrollment.js +117 -9
  32. package/package.json +2 -3
  33. package/presets/fleet/agent_templates/Critic.yaml +1 -1
  34. package/presets/fleet/agent_templates/Developer.yaml +1 -1
  35. package/presets/fleet/agent_templates/Engineer.yaml +1 -1
  36. package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -1
  37. package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
  38. package/presets/fleet/roles/Coordinator.yaml +6 -0
  39. package/presets/manifest.json +1 -1
  40. package/dist/web-app/assets/FleetApp-D58F4xje.css +0 -1
  41. package/dist/web-app/assets/FleetApp-Gjz3h8uS.js +0 -302
  42. package/dist/web-app/assets/FleetApp-Gjz3h8uS.js.map +0 -1
  43. package/dist/web-app/assets/abnfDiagram-VCTEODGH-t4h5V8Cs.js +0 -2
  44. package/dist/web-app/assets/abnfDiagram-VCTEODGH-t4h5V8Cs.js.map +0 -1
  45. package/dist/web-app/assets/arc-DbufDTF6.js +0 -2
  46. package/dist/web-app/assets/arc-DbufDTF6.js.map +0 -1
  47. package/dist/web-app/assets/architectureDiagram-5GKGNRK7-D6K8jMN5.js +0 -37
  48. package/dist/web-app/assets/architectureDiagram-5GKGNRK7-D6K8jMN5.js.map +0 -1
  49. package/dist/web-app/assets/blockDiagram-I7D4REHJ-CWvkET2x.js +0 -130
  50. package/dist/web-app/assets/blockDiagram-I7D4REHJ-CWvkET2x.js.map +0 -1
  51. package/dist/web-app/assets/browser-B7y08DsC.js +0 -9
  52. package/dist/web-app/assets/browser-B7y08DsC.js.map +0 -1
  53. package/dist/web-app/assets/c4Diagram-7LVT6UL2-RNWA7VnD.js +0 -39
  54. package/dist/web-app/assets/c4Diagram-7LVT6UL2-RNWA7VnD.js.map +0 -1
  55. package/dist/web-app/assets/channel-BF3U9sI8.js +0 -2
  56. package/dist/web-app/assets/channel-BF3U9sI8.js.map +0 -1
  57. package/dist/web-app/assets/chunk-2Q5K7J3B-pdEuY4vj.js +0 -2
  58. package/dist/web-app/assets/chunk-2Q5K7J3B-pdEuY4vj.js.map +0 -1
  59. package/dist/web-app/assets/chunk-5VM5RSS4-DuAUtdUC.js +0 -16
  60. package/dist/web-app/assets/chunk-5VM5RSS4-DuAUtdUC.js.map +0 -1
  61. package/dist/web-app/assets/chunk-F27PBJKO-6kpb80Xt.js +0 -2
  62. package/dist/web-app/assets/chunk-F27PBJKO-6kpb80Xt.js.map +0 -1
  63. package/dist/web-app/assets/chunk-IMKFNOWR-DWC84P6A.js +0 -232
  64. package/dist/web-app/assets/chunk-IMKFNOWR-DWC84P6A.js.map +0 -1
  65. package/dist/web-app/assets/chunk-JWPE2WC7-FHO-M3vi.js +0 -2
  66. package/dist/web-app/assets/chunk-JWPE2WC7-FHO-M3vi.js.map +0 -1
  67. package/dist/web-app/assets/chunk-POPQ4Y6H-CjnlKvmR.js +0 -2
  68. package/dist/web-app/assets/chunk-POPQ4Y6H-CjnlKvmR.js.map +0 -1
  69. package/dist/web-app/assets/chunk-SVP7TREG-WHYWJ6xq.js +0 -89
  70. package/dist/web-app/assets/chunk-SVP7TREG-WHYWJ6xq.js.map +0 -1
  71. package/dist/web-app/assets/chunk-TICWLB2K-POupMpyL.js +0 -207
  72. package/dist/web-app/assets/chunk-TICWLB2K-POupMpyL.js.map +0 -1
  73. package/dist/web-app/assets/chunk-XXDRQBXY-Cw5HHqH-.js +0 -2
  74. package/dist/web-app/assets/chunk-XXDRQBXY-Cw5HHqH-.js.map +0 -1
  75. package/dist/web-app/assets/classDiagram-ZZMXUADV-1hdMzIme.js +0 -2
  76. package/dist/web-app/assets/classDiagram-ZZMXUADV-1hdMzIme.js.map +0 -1
  77. package/dist/web-app/assets/classDiagram-v2-VYDZK3BY-1hdMzIme.js +0 -2
  78. package/dist/web-app/assets/classDiagram-v2-VYDZK3BY-1hdMzIme.js.map +0 -1
  79. package/dist/web-app/assets/cose-bilkent-JH36ORCC-Cp6Hjine.js +0 -2
  80. package/dist/web-app/assets/cose-bilkent-JH36ORCC-Cp6Hjine.js.map +0 -1
  81. package/dist/web-app/assets/cynefin-OW5HDTMX-DI-DeNUk.js +0 -179
  82. package/dist/web-app/assets/cynefin-OW5HDTMX-DI-DeNUk.js.map +0 -1
  83. package/dist/web-app/assets/cynefinDiagram-5FMLGOSQ-todBCANI.js +0 -63
  84. package/dist/web-app/assets/cynefinDiagram-5FMLGOSQ-todBCANI.js.map +0 -1
  85. package/dist/web-app/assets/cytoscape.esm-CtNdOvlO.js +0 -332
  86. package/dist/web-app/assets/cytoscape.esm-CtNdOvlO.js.map +0 -1
  87. package/dist/web-app/assets/dagre-GXQ25YYZ-BvIVo7eh.js +0 -5
  88. package/dist/web-app/assets/dagre-GXQ25YYZ-BvIVo7eh.js.map +0 -1
  89. package/dist/web-app/assets/defaultLocale-DX6XiGOO.js +0 -2
  90. package/dist/web-app/assets/defaultLocale-DX6XiGOO.js.map +0 -1
  91. package/dist/web-app/assets/diagram-S7CK7UJ4-k9vDGlB5.js +0 -31
  92. package/dist/web-app/assets/diagram-S7CK7UJ4-k9vDGlB5.js.map +0 -1
  93. package/dist/web-app/assets/diagram-UQ7AKVKN-Bv7BT1dE.js +0 -42
  94. package/dist/web-app/assets/diagram-UQ7AKVKN-Bv7BT1dE.js.map +0 -1
  95. package/dist/web-app/assets/diagram-VSXAHHWV-m9I9AumX.js +0 -4
  96. package/dist/web-app/assets/diagram-VSXAHHWV-m9I9AumX.js.map +0 -1
  97. package/dist/web-app/assets/diagram-VX7I27RA-4VJ2qozS.js +0 -25
  98. package/dist/web-app/assets/diagram-VX7I27RA-4VJ2qozS.js.map +0 -1
  99. package/dist/web-app/assets/diagram-Z3DM3KII-CvH8JIEd.js +0 -25
  100. package/dist/web-app/assets/diagram-Z3DM3KII-CvH8JIEd.js.map +0 -1
  101. package/dist/web-app/assets/ebnfDiagram-PWID7BFC-DAGs23hd.js +0 -2
  102. package/dist/web-app/assets/ebnfDiagram-PWID7BFC-DAGs23hd.js.map +0 -1
  103. package/dist/web-app/assets/erDiagram-RLTQ6QDP-CEMyWKIa.js +0 -100
  104. package/dist/web-app/assets/erDiagram-RLTQ6QDP-CEMyWKIa.js.map +0 -1
  105. package/dist/web-app/assets/flowDiagram-HODETNUW-BnraxJTV.js +0 -169
  106. package/dist/web-app/assets/flowDiagram-HODETNUW-BnraxJTV.js.map +0 -1
  107. package/dist/web-app/assets/ganttDiagram-EL5Y4UJY-V6d4ICW0.js +0 -293
  108. package/dist/web-app/assets/ganttDiagram-EL5Y4UJY-V6d4ICW0.js.map +0 -1
  109. package/dist/web-app/assets/gitGraphDiagram-WWUBYQGX-Bj_gqcba.js +0 -107
  110. package/dist/web-app/assets/gitGraphDiagram-WWUBYQGX-Bj_gqcba.js.map +0 -1
  111. package/dist/web-app/assets/index-C4BdeFB9.css +0 -1
  112. package/dist/web-app/assets/index-i9rKiqYX.js +0 -430
  113. package/dist/web-app/assets/index-i9rKiqYX.js.map +0 -1
  114. package/dist/web-app/assets/infoDiagram-27XIBGKW-Dfm89NLl.js +0 -3
  115. package/dist/web-app/assets/infoDiagram-27XIBGKW-Dfm89NLl.js.map +0 -1
  116. package/dist/web-app/assets/init-Gi6I4Gst.js +0 -2
  117. package/dist/web-app/assets/init-Gi6I4Gst.js.map +0 -1
  118. package/dist/web-app/assets/ishikawaDiagram-5VMMS53U-D-ISiJTS.js +0 -71
  119. package/dist/web-app/assets/ishikawaDiagram-5VMMS53U-D-ISiJTS.js.map +0 -1
  120. package/dist/web-app/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  121. package/dist/web-app/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  122. package/dist/web-app/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  123. package/dist/web-app/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  124. package/dist/web-app/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  125. package/dist/web-app/assets/journeyDiagram-3NMN7TZE-TTySrzws.js +0 -140
  126. package/dist/web-app/assets/journeyDiagram-3NMN7TZE-TTySrzws.js.map +0 -1
  127. package/dist/web-app/assets/kanban-definition-UXKFOSKX-BxntsBft.js +0 -90
  128. package/dist/web-app/assets/kanban-definition-UXKFOSKX-BxntsBft.js.map +0 -1
  129. package/dist/web-app/assets/katex-HP8lGamR.js +0 -258
  130. package/dist/web-app/assets/katex-HP8lGamR.js.map +0 -1
  131. package/dist/web-app/assets/layout-0sZak8Ho.js +0 -2
  132. package/dist/web-app/assets/layout-0sZak8Ho.js.map +0 -1
  133. package/dist/web-app/assets/linear-D4xk4kUi.js +0 -2
  134. package/dist/web-app/assets/linear-D4xk4kUi.js.map +0 -1
  135. package/dist/web-app/assets/mermaid.core-CNKolAgz.js +0 -313
  136. package/dist/web-app/assets/mermaid.core-CNKolAgz.js.map +0 -1
  137. package/dist/web-app/assets/mindmap-definition-YA3MSWOX-DLJijY_l.js +0 -97
  138. package/dist/web-app/assets/mindmap-definition-YA3MSWOX-DLJijY_l.js.map +0 -1
  139. package/dist/web-app/assets/ordinal-Cboi1Yqb.js +0 -2
  140. package/dist/web-app/assets/ordinal-Cboi1Yqb.js.map +0 -1
  141. package/dist/web-app/assets/pegDiagram-XKGWAZYB-xjrh9rBd.js +0 -2
  142. package/dist/web-app/assets/pegDiagram-XKGWAZYB-xjrh9rBd.js.map +0 -1
  143. package/dist/web-app/assets/pieDiagram-E7YTZNPT-CHE8ZMXN.js +0 -40
  144. package/dist/web-app/assets/pieDiagram-E7YTZNPT-CHE8ZMXN.js.map +0 -1
  145. package/dist/web-app/assets/quadrantDiagram-AXDQQJYC-CDlEe9sg.js +0 -8
  146. package/dist/web-app/assets/quadrantDiagram-AXDQQJYC-CDlEe9sg.js.map +0 -1
  147. package/dist/web-app/assets/railroadDiagram-O6MQD6OU-Dv7lFZ_v.js +0 -2
  148. package/dist/web-app/assets/railroadDiagram-O6MQD6OU-Dv7lFZ_v.js.map +0 -1
  149. package/dist/web-app/assets/requirementDiagram-BXWQKSXE-BetEel-k.js +0 -85
  150. package/dist/web-app/assets/requirementDiagram-BXWQKSXE-BetEel-k.js.map +0 -1
  151. package/dist/web-app/assets/sankeyDiagram-P5KCCOFB-DCQgLfvK.js +0 -41
  152. package/dist/web-app/assets/sankeyDiagram-P5KCCOFB-DCQgLfvK.js.map +0 -1
  153. package/dist/web-app/assets/sequenceDiagram-WJ2MYXX4-CsGOoqu_.js +0 -163
  154. package/dist/web-app/assets/sequenceDiagram-WJ2MYXX4-CsGOoqu_.js.map +0 -1
  155. package/dist/web-app/assets/sizeCapture-INFHLROL-CLQRMiEc.js +0 -2
  156. package/dist/web-app/assets/sizeCapture-INFHLROL-CLQRMiEc.js.map +0 -1
  157. package/dist/web-app/assets/stateDiagram-D77RDMKH-D2MRdC5Q.js +0 -2
  158. package/dist/web-app/assets/stateDiagram-D77RDMKH-D2MRdC5Q.js.map +0 -1
  159. package/dist/web-app/assets/stateDiagram-v2-MP3YSRHH-CToAT5fD.js +0 -2
  160. package/dist/web-app/assets/stateDiagram-v2-MP3YSRHH-CToAT5fD.js.map +0 -1
  161. package/dist/web-app/assets/swimlanes-42K2YHIH-BGjHfR8x.js +0 -2
  162. package/dist/web-app/assets/swimlanes-42K2YHIH-BGjHfR8x.js.map +0 -1
  163. package/dist/web-app/assets/swimlanesDiagram-VR7AAH4N-bLHg88Vk.js +0 -9
  164. package/dist/web-app/assets/swimlanesDiagram-VR7AAH4N-bLHg88Vk.js.map +0 -1
  165. package/dist/web-app/assets/timeline-definition-24CTP7MA-DhGtHQ0p.js +0 -121
  166. package/dist/web-app/assets/timeline-definition-24CTP7MA-DhGtHQ0p.js.map +0 -1
  167. package/dist/web-app/assets/vennDiagram-4TSXK5OY-Dv5irf00.js +0 -35
  168. package/dist/web-app/assets/vennDiagram-4TSXK5OY-Dv5irf00.js.map +0 -1
  169. package/dist/web-app/assets/wardleyDiagram-VM6X3IG4-JGiUTj5x.js +0 -79
  170. package/dist/web-app/assets/wardleyDiagram-VM6X3IG4-JGiUTj5x.js.map +0 -1
  171. package/dist/web-app/assets/xychartDiagram-S5SC5T6Z-Dw5ev7_Q.js +0 -8
  172. package/dist/web-app/assets/xychartDiagram-S5SC5T6Z-Dw5ev7_Q.js.map +0 -1
  173. package/dist/web-app/fleet-index.html +0 -19
  174. package/dist/web-app/fleet.webmanifest +0 -37
  175. package/dist/web-app/icons/apple-touch-icon.png +0 -0
  176. package/dist/web-app/icons/favicon-16.png +0 -0
  177. package/dist/web-app/icons/favicon-32.png +0 -0
  178. package/dist/web-app/icons/icon-192-maskable.png +0 -0
  179. package/dist/web-app/icons/icon-192.png +0 -0
  180. package/dist/web-app/icons/icon-512-maskable.png +0 -0
  181. package/dist/web-app/icons/icon-512.png +0 -0
  182. package/dist/web-app/index.html +0 -19
  183. package/dist/web-app/manifest.webmanifest +0 -17
  184. package/dist/web-app/sw.js +0 -221
  185. package/dist/web-app/version.json +0 -1
  186. package/dist/web-app/workspace-install.sh +0 -148
  187. package/dist/web-app/workspace-tunnel.sh +0 -40
@@ -81,11 +81,18 @@ export class CodexAppServer {
81
81
  this.pending.clear();
82
82
  });
83
83
  }
84
- static async start(bin, env) {
84
+ static async start(bin, env, initializeTimeoutMs = 20_000) {
85
85
  const child = spawn(bin, ['app-server'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
86
86
  await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
87
87
  const server = new CodexAppServer(child);
88
- await server.call('initialize', { clientInfo: { name: 'ours-fleet', title: 'Ours Fleet', version: '1' } });
88
+ // A server that does not answer is not handed out, so nobody else can close it: end it here.
89
+ try {
90
+ await server.call('initialize', { clientInfo: { name: 'ours-fleet', title: 'Ours Fleet', version: '1' } }, initializeTimeoutMs);
91
+ }
92
+ catch (error) {
93
+ server.close();
94
+ throw error;
95
+ }
89
96
  child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
90
97
  return server;
91
98
  }
@@ -140,8 +147,8 @@ export class CodexAppServer {
140
147
  }
141
148
  }
142
149
  /** One-shot use of an app-server for a profile. */
143
- export async function withCodex(bin, env, fn) {
144
- const server = await CodexAppServer.start(bin, env);
150
+ export async function withCodex(bin, env, fn, initializeTimeoutMs) {
151
+ const server = await CodexAppServer.start(bin, env, initializeTimeoutMs);
145
152
  try {
146
153
  return await fn(server);
147
154
  }
@@ -149,6 +156,36 @@ export async function withCodex(bin, env, fn) {
149
156
  server.close();
150
157
  }
151
158
  }
159
+ /**
160
+ * Ask this Codex executable, with this profile's environment, which models it offers (`model/list`,
161
+ * the call the ACP adapter itself uses). Nothing is inferred when it does not answer.
162
+ */
163
+ export async function codexOfferedModels(bin, env, budgetMs = 30_000) {
164
+ // One budget for the whole question: onboarding waits for this answer.
165
+ const deadline = Date.now() + budgetMs;
166
+ const left = () => Math.max(1, deadline - Date.now());
167
+ try {
168
+ return await withCodex(bin, env, async (server) => {
169
+ const models = [];
170
+ let cursor = null;
171
+ while (Date.now() < deadline) {
172
+ const answer = await server.call('model/list', { cursor, limit: null }, left());
173
+ if (!Array.isArray(answer?.data))
174
+ return { state: 'unknown', reason: 'Codex did not return a model list' };
175
+ for (const model of answer.data)
176
+ if (model && typeof model.id === 'string' && model.id && model.hidden !== true)
177
+ models.push(model.id);
178
+ cursor = typeof answer.nextCursor === 'string' && answer.nextCursor ? answer.nextCursor : null;
179
+ if (!cursor)
180
+ return { state: 'known', models: [...new Set(models)] };
181
+ }
182
+ return { state: 'unknown', reason: 'Codex did not finish listing its models in time' };
183
+ }, left());
184
+ }
185
+ catch (error) {
186
+ return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) };
187
+ }
188
+ }
152
189
  /** Flatten `account/rateLimits/read` into display windows. */
153
190
  export function codexUsageWindows(result) {
154
191
  const snapshots = result.rateLimitsByLimitId && Object.keys(result.rateLimitsByLimitId).length
@@ -54,7 +54,7 @@ export declare class WebAuth {
54
54
  mintBootstrap(): string;
55
55
  /** The configured account origin, when this request comes from it and workspace devices are enabled. */
56
56
  accountOrigin(request: Pick<FastifyRequest, 'headers'>): string | undefined;
57
- validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
57
+ validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
58
58
  exchange(request: FastifyRequest): AuthResult;
59
59
  login(request: FastifyRequest, password: string): AuthResult;
60
60
  anonymous(request: FastifyRequest): BrowserSession;
package/dist/web/auth.js CHANGED
@@ -58,7 +58,7 @@ export class WebAuth {
58
58
  accountOrigin(request) {
59
59
  return this.workspaceDevices && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
60
60
  }
61
- validateBoundary(request, requireOrigin, allowPageNavigation = false) {
61
+ validateBoundary(request, requireOrigin) {
62
62
  const host = request.headers.host;
63
63
  const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
64
64
  const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
@@ -71,10 +71,7 @@ export class WebAuth {
71
71
  if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
72
72
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
73
73
  const fetchSite = request.headers['sec-fetch-site'];
74
- const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
75
- && request.headers['sec-fetch-mode'] === 'navigate'
76
- && (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'));
77
- if (!workspaceRequest && !pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
74
+ if (!workspaceRequest && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
78
75
  throw new FleetError('forbidden', 'cross-site request rejected');
79
76
  }
80
77
  exchange(request) {
@@ -1,4 +1,4 @@
1
- export type WebControlCommand = 'open' | 'revoke-all' | 'link-device';
1
+ export type WebControlCommand = 'revoke-all' | 'link-device';
2
2
  export declare const webControlPath: (dir?: string) => string;
3
3
  export interface WebControlServer {
4
4
  path: string;
@@ -6,7 +6,6 @@ export interface WebControlServer {
6
6
  }
7
7
  export declare function startWebControlServer(options: {
8
8
  dir?: string;
9
- onOpen(): void | Promise<void>;
10
9
  onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
11
10
  onRevokeAll(): void | Promise<void>;
12
11
  now?: () => number;
@@ -38,8 +38,6 @@ export async function startWebControlServer(options) {
38
38
  let result;
39
39
  if (parsed.command === 'link-device' && options.onLinkDevice)
40
40
  result = await options.onLinkDevice();
41
- else if (parsed.command === 'open')
42
- await options.onOpen();
43
41
  else if (parsed.command === 'revoke-all')
44
42
  await options.onRevokeAll();
45
43
  else
@@ -27,7 +27,7 @@ export function createPrefixGateway(options) {
27
27
  const path = raw.split('?')[0];
28
28
  const service = services.find(x => path === x.prefix || path.startsWith(x.prefix + '/'));
29
29
  const fleet = path === '/fleet/api' || path.startsWith('/fleet/api/');
30
- return { service, page: !service && !fleet && !path.startsWith('/api/'), origin: service?.origin ?? options.fleetOrigin,
30
+ return { service, origin: service?.origin ?? options.fleetOrigin,
31
31
  path: service ? (service.upstreamPrefix || '') + (raw.slice(service.prefix.length) || '/').replace(/^\?/, '/?') : fleet ? raw.slice('/fleet'.length) : raw };
32
32
  };
33
33
  const prepare = (req, upgrade = false) => {
@@ -35,7 +35,7 @@ export function createPrefixGateway(options) {
35
35
  const request = { headers: req.headers, method: req.method, url: route.path };
36
36
  if (!auth && (req.headers.origin || req.headers['sec-fetch-site'] || !route.service))
37
37
  throw new Error('machine-client listener rejects browser requests');
38
- auth?.validateBoundary(request, upgrade, route.page);
38
+ auth?.validateBoundary(request, upgrade);
39
39
  const session = route.service && auth ? auth.authenticate(request, !upgrade && !['GET', 'HEAD', 'OPTIONS'].includes(req.method ?? 'GET')) : undefined;
40
40
  const headers = headersFor(req);
41
41
  // Forward only the explicit browser context, never an attacker-selected forwarding chain.
@@ -100,11 +100,6 @@ export function createPrefixGateway(options) {
100
100
  res.end(JSON.stringify({ error: { code: code ?? 'forbidden', message: error.message } }));
101
101
  return;
102
102
  }
103
- if (req.url === '/') {
104
- res.writeHead(302, { location: '/fleet' });
105
- res.end();
106
- return;
107
- }
108
103
  const upstream = (route.origin.startsWith('https:') ? httpsRequest : httpRequest)(route.origin, {
109
104
  method: req.method, path: route.path, headers: route.headers,
110
105
  }, response => {
@@ -5,9 +5,7 @@ export interface StartWebOptions {
5
5
  /** Separate console state for an independently launched gateway. */
6
6
  webStateDir?: string;
7
7
  control?: boolean;
8
- staticRoot?: string;
9
8
  port?: number;
10
- open?: boolean;
11
9
  binPath: string;
12
10
  log?(line: string): void;
13
11
  bind?: string;
@@ -6,10 +6,10 @@ import { readClientProfile } from '../client-profile.js';
6
6
  import { readPrivateFile } from '@ours.network/sdk/connector';
7
7
  import { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
8
8
  import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
9
- import { spawn } from 'node:child_process';
10
9
  import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
11
10
  import { producerConfig } from '../notifications/outbox.js';
12
11
  import { SubscriptionService } from '../subscriptions/service.js';
12
+ import { createCodexModelDiscovery } from '../application/codex-models.js';
13
13
  import { existsSync, mkdirSync, realpathSync, readFileSync } from 'node:fs';
14
14
  import { resolve } from 'node:path';
15
15
  import { loadConfig } from '../config.js';
@@ -202,6 +202,7 @@ export async function startWebConsole(options) {
202
202
  publish: publishSetup,
203
203
  });
204
204
  },
205
+ codexModels: createCodexModelDiscovery(),
205
206
  subscriptions: new SubscriptionService({
206
207
  agents: async () => (await query.list(true)).map(({ role, status }) => ({
207
208
  roleId: role.id, stateDir: repository.stateDir(role), running: status.supervisor.liveness === 'running',
@@ -221,7 +222,7 @@ export async function startWebConsole(options) {
221
222
  }
222
223
  throw new FleetError('capability_unavailable', 'role session backend is unavailable');
223
224
  },
224
- }, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth, staticRoot: options.staticRoot });
225
+ }, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth });
225
226
  }
226
227
  catch (error) {
227
228
  lock.release();
@@ -282,11 +283,6 @@ export async function startWebConsole(options) {
282
283
  if (options.control !== false)
283
284
  control = await startWebControlServer({
284
285
  dir: webDir,
285
- onOpen() {
286
- const url = access.mode === 'pairing'
287
- ? `${browserOrigin}/#bootstrap=${server.auth.mintBootstrap()}` : `${browserOrigin}/`;
288
- openBrowser(url);
289
- },
290
286
  onLinkDevice() { return { version: 1, origin: browserOrigin, ...server.auth.workspaceEnrollment() }; },
291
287
  onRevokeAll() { server.auth.revokeAllTrustedDevices(); },
292
288
  });
@@ -297,9 +293,6 @@ export async function startWebConsole(options) {
297
293
  lock.release();
298
294
  throw error;
299
295
  }
300
- if (options.open !== false)
301
- openBrowser(access.mode === 'pairing'
302
- ? `${browserOrigin}/#bootstrap=${server.auth.bootstrapSecret}` : `${browserOrigin}/`);
303
296
  let stopTunnel;
304
297
  try {
305
298
  stopTunnel = startWorkspaceTunnel(browserOrigin);
@@ -335,11 +328,3 @@ export async function startWebConsole(options) {
335
328
  function isLoopback(host) {
336
329
  return ['127.0.0.1', 'localhost', '::1'].includes(host);
337
330
  }
338
- function openBrowser(url) {
339
- const command = process.platform === 'darwin' ? 'open'
340
- : process.platform === 'win32' ? 'cmd' : 'xdg-open';
341
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
342
- const child = spawn(command, args, { stdio: 'ignore', detached: true, shell: false });
343
- child.on('error', () => undefined);
344
- child.unref();
345
- }
@@ -19,6 +19,7 @@ import type { RoleRemovalService } from '../application/role-removal-service.js'
19
19
  import type { TaskRoomApplicationService } from '../application/task-room-service.js';
20
20
  import type { RoomLayoutDefinitions } from '../application/room-layout-definitions.js';
21
21
  import type { PresetProvenance } from '../application/preset-provenance.js';
22
+ import type { OfferedModels } from '../subscriptions/cli.js';
22
23
  import type { SubscriptionService } from '../subscriptions/service.js';
23
24
  import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
24
25
  export interface WebServices {
@@ -44,6 +45,8 @@ export interface WebServices {
44
45
  subscriptions?: SubscriptionService;
45
46
  /** First-time setup from the packaged presets; the account App supplies only the model choices. */
46
47
  onboardingSetup?(answers: InitAnswers): Promise<void>;
48
+ /** The Codex models the runtime that sessions will use offers the active account. Absent: this server cannot tell, and filters nothing. */
49
+ codexModels?(): Promise<OfferedModels>;
47
50
  }
48
51
  export interface WebServer {
49
52
  app: FastifyInstance;
@@ -57,5 +60,4 @@ export declare function buildWebServer(services: WebServices, boundary: {
57
60
  host: string;
58
61
  }, options?: {
59
62
  auth?: WebAuth;
60
- staticRoot?: string;
61
63
  }): Promise<WebServer>;
@@ -6,11 +6,8 @@ import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentA
6
6
  import { readChatIdle } from '../temp-idle.js';
7
7
  import { agentDir } from '../paths.js';
8
8
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
9
- import { existsSync } from 'node:fs';
10
- import { dirname, join } from 'node:path';
11
- import { fileURLToPath } from 'node:url';
9
+ import { join } from 'node:path';
12
10
  import Fastify from 'fastify';
13
- import fastifyStatic from '@fastify/static';
14
11
  import fastifyWebsocket from '@fastify/websocket';
15
12
  import { createHmac, randomBytes } from 'node:crypto';
16
13
  import { FleetError, normalizeError } from '../application/errors.js';
@@ -44,10 +41,8 @@ export async function buildWebServer(services, boundary, options = {}) {
44
41
  reply.header('X-Content-Type-Options', 'nosniff');
45
42
  reply.header('Referrer-Policy', 'no-referrer');
46
43
  reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
47
- // Only the configured account launcher may embed workspace pages.
48
- reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; ` +
49
- `connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
50
- `frame-ancestors 'self' ${auth.appOrigin}; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
44
+ // Fleet serves an API only: nothing it returns may run, load or be framed as a page.
45
+ reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
51
46
  if (request.url.startsWith('/api/'))
52
47
  reply.header('Cache-Control', 'no-store');
53
48
  if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
@@ -59,15 +54,7 @@ export async function buildWebServer(services, boundary, options = {}) {
59
54
  return reply.code(204).send();
60
55
  }
61
56
  }
62
- try {
63
- auth.validateBoundary(request, false, !request.url.startsWith('/api/'));
64
- }
65
- catch (error) {
66
- if (request.url.startsWith('/api/'))
67
- throw error;
68
- const message = normalizeError(error).message;
69
- return reply.code(421).type('text/html').send(`<!doctype html><html><head><title>Fleet console address</title></head><body><main><h1>This fleet-console address is not configured</h1><p>${escapeHtml(message)}</p><p>For nginx or VPS access, configure an explicit public origin. The console does not guess proxy hosts.</p></main></body></html>`);
70
- }
57
+ auth.validateBoundary(request, false);
71
58
  });
72
59
  app.post('/api/v1/layout-bindings/:id/control', async (request) => {
73
60
  // Initial support is direct same-host Fleet-to-Fleet transport.
@@ -166,7 +153,11 @@ export async function buildWebServer(services, boundary, options = {}) {
166
153
  }
167
154
  }));
168
155
  const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
169
- return { harnesses, catalog: catalog.models, providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
156
+ // Codex models are offered only when the runtime itself listed them for the active account.
157
+ // When it could not be asked, none is offered and the reason is reported.
158
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
159
+ const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
160
+ return { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
170
161
  });
171
162
  // First-time setup is what creates the persistent coordinator. A configuration that loads and
172
163
  // has it is set up, whatever its brains are called; its models are then changed by editing it.
@@ -186,7 +177,9 @@ export async function buildWebServer(services, boundary, options = {}) {
186
177
  if (!services.configuration || !services.onboardingSetup)
187
178
  throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
188
179
  const requested = request.body?.models;
189
- const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models;
180
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
181
+ const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
182
+ .filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
190
183
  const models = {};
191
184
  for (const work of ONBOARDING_WORK) {
192
185
  const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
@@ -1022,19 +1015,8 @@ export async function buildWebServer(services, boundary, options = {}) {
1022
1015
  socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
1023
1016
  });
1024
1017
  });
1025
- const staticRoot = options.staticRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
1026
- if (existsSync(staticRoot)) {
1027
- await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
1028
- app.setNotFoundHandler((request, reply) => {
1029
- if (request.url.startsWith('/api/'))
1030
- return reply.code(404).send({ error: 'not found' });
1031
- const path = request.url.split('?')[0];
1032
- if ((path === '/chats' || path.startsWith('/chats/')) && existsSync(join(staticRoot, 'fleet-index.html')))
1033
- return reply.redirect((request.url.includes('?') ? '/fleet/chats' : '/fleet') + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''));
1034
- const fleetEntry = (path === '/fleet' || path.startsWith('/fleet/')) && existsSync(join(staticRoot, 'fleet-index.html'));
1035
- return reply.sendFile(fleetEntry ? 'fleet-index.html' : 'index.html');
1036
- });
1037
- }
1018
+ // Fleet has no pages of its own; every screen belongs to the App.
1019
+ app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'not found' }));
1038
1020
  return {
1039
1021
  app, auth, audit, events,
1040
1022
  async close() {
@@ -1071,9 +1053,6 @@ function promoteRequest(body) {
1071
1053
  };
1072
1054
  }
1073
1055
  function cryptoRandomId() { return randomBytes(12).toString('hex'); }
1074
- function escapeHtml(value) {
1075
- return value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
1076
- }
1077
1056
  function requireSubprotocol(request, expected) {
1078
1057
  const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
1079
1058
  .split(',').map(value => value.trim());
@@ -16,6 +16,11 @@ export interface WorkspacePayload {
16
16
  };
17
17
  }
18
18
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
19
+ /**
20
+ * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
21
+ * Reads only; an existing Owner is compared with this host's own Messenger identity.
22
+ */
23
+ export declare function checkWorkspaceConfiguration(configuration?: string): Promise<void>;
19
24
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
20
25
  migrateAppOrigin?: boolean;
21
26
  preserveProfile?: boolean;
@@ -25,4 +30,16 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
25
30
  rootCid: any;
26
31
  }>;
27
32
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
33
+ /**
34
+ * The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
35
+ * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
36
+ * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
37
+ */
38
+ export declare function removeEnrollmentContact(): Promise<boolean>;
28
39
  export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
40
+ /**
41
+ * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
42
+ * only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
43
+ * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
44
+ */
45
+ export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
@@ -1,11 +1,12 @@
1
1
  import { validateAccountOrigin } from './account-origin.js';
2
- import { lstatSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { readClientProfile } from './client-profile.js';
5
- import { stateRoot, defaultConfigPath } from './paths.js';
5
+ import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
6
  import { replaceFileAtomically } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
- import { stringify } from 'yaml';
8
+ import { parse, stringify } from 'yaml';
9
+ import { preflightInitPaths } from './init-wizard.js';
9
10
  import { WebAccessStore } from './web/access.js';
10
11
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
11
12
  export function readWorkspacePayload(file) {
@@ -26,17 +27,85 @@ export function readWorkspacePayload(file) {
26
27
  throw Error('Invalid workspace payload');
27
28
  return p;
28
29
  }
29
- export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
30
+ const GENERATED_VERSION = 'ours.network/fleet/v2';
31
+ /** The rooms section tunnel setup writes for the enrolled Human root. */
32
+ const generatedRooms = (rootCid, inviteFile) => ({ rooms: { owner: { provider: 'messenger-server', expected_cid: rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } });
33
+ /**
34
+ * True only when this configuration is, key for key, what tunnel setup generated itself and nothing
35
+ * else was added beside it. Only then may its Owner entry be treated as the record of an enrollment.
36
+ */
37
+ function generatedConfigurationOnly(configuration) {
38
+ let document;
39
+ try {
40
+ document = parse(readFileSync(configuration, 'utf8'));
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ const only = (value, keys) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join() === [...keys].sort().join();
46
+ if (!only(document, ['api_version', 'rooms']) || document.api_version !== GENERATED_VERSION)
47
+ return false;
48
+ const rooms = document.rooms;
49
+ if (!only(rooms, ['owner', 'defaults']) || !only(rooms.owner, ['provider', 'expected_cid', 'public_invite_file', 'role']) || !only(rooms.defaults, ['attach_owner', 'close_when_task_done']))
50
+ return false;
51
+ if (rooms.owner.provider !== 'messenger-server' || rooms.owner.role !== 'Owner' || rooms.owner.public_invite_file !== workspaceOwnerInvite() || typeof rooms.owner.expected_cid !== 'string' || !/^[a-f0-9]{64}$/i.test(rooms.owner.expected_cid)
52
+ || rooms.defaults.attach_owner !== true || rooms.defaults.close_when_task_done !== true)
53
+ return false;
54
+ const split = splitRootFor(configuration);
55
+ if (!existsSync(split))
56
+ return true;
57
+ const entries = readdirSync(split);
58
+ return entries.every(entry => entry === 'agents') && (!entries.length || (lstatSync(join(split, 'agents')).isDirectory() && readdirSync(join(split, 'agents')).length === 0));
59
+ }
60
+ /**
61
+ * The Owner entry of a generated configuration records an enrollment whose host state is gone:
62
+ * no binding and no invitation remain. Nothing else is ever replaced.
63
+ */
64
+ const recordsLostEnrollment = (configuration) => !existsSync(join(stateRoot(), 'workspace', 'binding.json')) && !existsSync(workspaceOwnerInvite()) && generatedConfigurationOnly(configuration);
65
+ function hostMessenger() {
30
66
  const profile = readClientProfile();
31
67
  const base = new URL(profile.endpoint);
32
68
  base.pathname = base.pathname.replace(/\/daemon\/?$/, '/messenger/');
33
69
  if (!base.pathname.endsWith('/messenger/'))
34
70
  throw Error('Workspace setup requires the supported gateway client profile');
35
71
  const secret = readFileSync(profile.credentialPath, 'utf8').trim();
36
- const request = async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
72
+ return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
37
73
  await response.body?.cancel();
38
74
  throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
39
75
  } return response.json(); };
76
+ }
77
+ /**
78
+ * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
79
+ * Reads only; an existing Owner is compared with this host's own Messenger identity.
80
+ */
81
+ export async function checkWorkspaceConfiguration(configuration = defaultConfigPath()) {
82
+ const paths = preflightInitPaths(configuration);
83
+ if (!paths.manifestExisted) {
84
+ if (paths.rootExisted)
85
+ throw Error(`${paths.splitRoot} exists without ${paths.configPath}. Restore that file or move the directory away.`);
86
+ return;
87
+ }
88
+ const loaded = loadConfig(paths.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
89
+ if (!loaded.rooms)
90
+ return;
91
+ if (loaded.rooms.defaults?.attach_owner === false)
92
+ throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
93
+ if (!loaded.rooms.owner.public_invite_file)
94
+ throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
95
+ let cid;
96
+ try {
97
+ cid = (await hostMessenger()('workspace/enrollment-identity')).cid;
98
+ }
99
+ catch {
100
+ cid = undefined;
101
+ }
102
+ if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
103
+ throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
104
+ if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath))
105
+ throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
106
+ }
107
+ export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
108
+ const request = hostMessenger();
40
109
  if (new WebAccessStore().read().mode === 'none')
41
110
  throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
42
111
  const dir = join(stateRoot(), 'workspace');
@@ -55,7 +124,9 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
55
124
  if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
56
125
  throw Error('Account origin migration must retain the enrollment server identity');
57
126
  }
58
- const loaded = loadConfig(configuration);
127
+ const loaded = loadConfig(configuration, { deferredOwnerInviteFile: workspaceOwnerInvite() });
128
+ // Decided before anything is sent or written: the Owner entry is replaced only when it records an enrollment this host has lost.
129
+ const lost = Boolean(loaded.rooms) && recordsLostEnrollment(resolve(configuration));
59
130
  if (loaded.rooms?.defaults?.attach_owner === false)
60
131
  throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
61
132
  if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
@@ -65,7 +136,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
65
136
  throw Error('Bound Messenger identity unavailable');
66
137
  if (options.preserveProfile && identity.preserveProfile !== true)
67
138
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
68
- if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
139
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
69
140
  throw Error('Retained room owner CID conflicts with the Human root');
70
141
  const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
71
142
  if (existsSync(inviteFile)) {
@@ -80,7 +151,10 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
80
151
  const result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
81
152
  if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
82
153
  throw Error('Malformed workspace enrollment response');
83
- if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
154
+ // Whatever the configuration said before, what is recorded from here on is this host's own root.
155
+ if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
156
+ throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
157
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
84
158
  throw Error('Retained room owner CID conflicts with the Human root');
85
159
  if (migrating) {
86
160
  const deadline = Date.now() + 15000;
@@ -102,7 +176,9 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
102
176
  const split = splitRootFor(configuration);
103
177
  mkdirSync(split, { recursive: true, mode: 0o700 });
104
178
  if (!loaded.rooms)
105
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify({ rooms: { owner: { provider: 'messenger-server', expected_cid: result.rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } }), 0o600);
179
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
180
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
181
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
106
182
  replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
107
183
  const origin = `https://${payload.hostname}`;
108
184
  replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
@@ -111,6 +187,29 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
111
187
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
112
188
  }
113
189
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
190
+ /**
191
+ * The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
192
+ * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
193
+ * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
194
+ */
195
+ export async function removeEnrollmentContact() {
196
+ let serverCid;
197
+ try {
198
+ serverCid = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')).serverCid;
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
204
+ return false;
205
+ try {
206
+ await hostMessenger()('contacts/remove', { contact: serverCid.toUpperCase() });
207
+ }
208
+ catch {
209
+ throw Error('Messenger did not remove it');
210
+ }
211
+ return true;
212
+ }
114
213
  export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid, port) {
115
214
  const deadline = Date.now() + 15000;
116
215
  while (true) {
@@ -127,3 +226,12 @@ export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid,
127
226
  await new Promise(resolve => setTimeout(resolve, 500));
128
227
  }
129
228
  }
229
+ /**
230
+ * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
231
+ * only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
232
+ * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
233
+ */
234
+ export async function confirmWorkspaceTarget(payload, hostWorkspaceId, rootCid, port, cleanupFailed = () => { }) {
235
+ await configureWorkspacePort(payload, hostWorkspaceId, rootCid, port);
236
+ await removeEnrollmentContact().catch(cleanupFailed);
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.34",
3
+ "version": "1.2.0-nightly.36",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
30
- "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-web.mjs && node scripts/build-info.mjs",
30
+ "build": "npm run clean && tsc -p tsconfig.json && node scripts/build-info.mjs",
31
31
  "typecheck": "tsc -p tsconfig.json --noEmit",
32
32
  "lint": "npm run typecheck",
33
33
  "test": "vitest run",
@@ -39,7 +39,6 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@agentclientprotocol/sdk": "^1.3.0",
42
- "@fastify/static": "^10.1.2",
43
42
  "@fastify/websocket": "^11.2.0",
44
43
  "@ours.network/cli": "2.8.1-nightly.13",
45
44
  "@ours.network/mcp": "1.2.0-nightly.10",
@@ -1,5 +1,5 @@
1
1
  role: { ref: Critic }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,5 +1,5 @@
1
1
  role: { ref: Developer }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,5 +1,5 @@
1
1
  role: { ref: Engineer }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,7 +1,7 @@
1
1
  role: { ref: LocalCoordinator }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
6
6
  loops:
7
7
  continuity: