@ours.network/fleet 1.2.0-nightly.7 → 1.2.0-nightly.71
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.
- package/README.md +279 -68
- package/dist/account-origin.d.ts +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/agent-ours/bridge.js +26 -9
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/file-rpc.d.ts +12 -0
- package/dist/agent-ours/file-rpc.js +18 -4
- package/dist/agent-ours/harness.js +9 -1
- package/dist/agent-ours/mcp-endpoint.d.ts +19 -0
- package/dist/agent-ours/mcp-endpoint.js +98 -25
- package/dist/agent-ours/runtime.d.ts +9 -1
- package/dist/agent-ours/runtime.js +50 -2
- package/dist/agent-ours/service.d.ts +24 -1
- package/dist/agent-ours/service.js +97 -13
- package/dist/application/codex-models.d.ts +15 -0
- package/dist/application/codex-models.js +56 -0
- package/dist/application/conversation-history.d.ts +34 -0
- package/dist/application/conversation-history.js +280 -0
- package/dist/application/conversation-role-observer.d.ts +62 -0
- package/dist/application/conversation-role-observer.js +206 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +3 -3
- package/dist/application/log-service.js +8 -8
- package/dist/application/model-catalog.d.ts +1 -1
- package/dist/application/model-catalog.js +5 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/resource-observer.d.ts +40 -0
- package/dist/application/resource-observer.js +278 -0
- package/dist/application/role-creation-service.js +3 -2
- package/dist/application/role-removal-service.js +17 -3
- package/dist/application/role-repository.d.ts +1 -1
- package/dist/application/role-repository.js +30 -7
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +61 -17
- package/dist/application/session-control.js +207 -23
- package/dist/application/supervisor-ours-tools.js +5 -11
- package/dist/application/task-layouts.d.ts +91 -0
- package/dist/application/task-layouts.js +261 -0
- package/dist/application/task-room-service.d.ts +101 -3
- package/dist/application/task-room-service.js +286 -33
- package/dist/application/types.d.ts +2 -0
- package/dist/atomic-file.d.ts +34 -1
- package/dist/atomic-file.js +161 -18
- package/dist/briefing.d.ts +3 -1
- package/dist/briefing.js +97 -31
- package/dist/build-info.json +9 -4
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.js +13 -0
- package/dist/cli.js +400 -37
- package/dist/client-profile.d.ts +5 -17
- package/dist/client-profile.js +4 -127
- package/dist/config.d.ts +45 -0
- package/dist/config.js +57 -6
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +4 -4
- package/dist/daemon-recovery.d.ts +14 -9
- package/dist/daemon-recovery.js +124 -115
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +207 -19
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +75 -60
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/file-delivery/reader.d.ts +15 -0
- package/dist/file-delivery/reader.js +63 -0
- package/dist/file-delivery/store.d.ts +15 -0
- package/dist/file-delivery/store.js +124 -0
- package/dist/file-delivery/types.d.ts +19 -0
- package/dist/file-delivery/types.js +1 -0
- package/dist/fleet-command-audit.d.ts +7 -0
- package/dist/fleet-command-audit.js +24 -8
- package/dist/harness/agent-session.d.ts +4 -0
- package/dist/harness/claude-code-session.js +1 -1
- package/dist/harness/claude-code.js +21 -11
- package/dist/harness/claude-runtime.d.ts +12 -0
- package/dist/harness/claude-runtime.js +43 -0
- package/dist/harness/codex-app-server-proxy.d.ts +4 -1
- package/dist/harness/codex-app-server-proxy.js +41 -5
- package/dist/harness/codex-reasoning.d.ts +5 -0
- package/dist/harness/codex-reasoning.js +12 -0
- package/dist/harness/codex-runtime.d.ts +11 -1
- package/dist/harness/codex-runtime.js +31 -4
- package/dist/harness/codex-session.js +2 -2
- package/dist/harness/codex.js +24 -12
- package/dist/init-wizard.d.ts +2 -0
- package/dist/init-wizard.js +38 -19
- package/dist/isolation/policy.js +6 -1
- package/dist/isolation/types.d.ts +5 -0
- package/dist/managed-cli.d.ts +253 -0
- package/dist/managed-cli.js +923 -0
- package/dist/managed-recovery.d.ts +13 -0
- package/dist/managed-recovery.js +31 -0
- package/dist/minimal-setup.d.ts +2 -0
- package/dist/minimal-setup.js +44 -0
- package/dist/monitor.d.ts +9 -34
- package/dist/monitor.js +90 -153
- package/dist/notifications/fleet-producer.d.ts +22 -0
- package/dist/notifications/fleet-producer.js +237 -0
- package/dist/notifications/outbox.d.ts +46 -0
- package/dist/notifications/outbox.js +158 -0
- package/dist/notifications/target-cleanup.d.ts +11 -0
- package/dist/notifications/target-cleanup.js +63 -0
- package/dist/notifications/task-producer.d.ts +20 -0
- package/dist/notifications/task-producer.js +100 -0
- package/dist/ops.d.ts +2 -0
- package/dist/ops.js +20 -3
- package/dist/owner-channel/channel.js +17 -6
- package/dist/owner-channel/commands.d.ts +4 -0
- package/dist/owner-channel/commands.js +8 -0
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +2 -0
- package/dist/preset-migration.js +43 -0
- package/dist/process-incarnation.d.ts +23 -0
- package/dist/process-incarnation.js +42 -0
- package/dist/reminders/manager.d.ts +75 -0
- package/dist/reminders/manager.js +278 -0
- package/dist/reminders/runtime.d.ts +27 -0
- package/dist/reminders/runtime.js +32 -0
- package/dist/reminders/schedule.d.ts +37 -0
- package/dist/reminders/schedule.js +104 -0
- package/dist/reminders/store.d.ts +341 -0
- package/dist/reminders/store.js +762 -0
- package/dist/reminders/tools.d.ts +20 -0
- package/dist/reminders/tools.js +114 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +84 -0
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +65 -5
- package/dist/rooms-tasks/close.d.ts +6 -2
- package/dist/rooms-tasks/close.js +246 -37
- package/dist/rooms-tasks/config.d.ts +1 -1
- package/dist/rooms-tasks/config.js +11 -4
- package/dist/rooms-tasks/cowork-adapter.d.ts +12 -5
- package/dist/rooms-tasks/cowork-adapter.js +15 -121
- package/dist/rooms-tasks/cowork-http.js +56 -6
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +84 -5
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +173 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -4
- package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
- package/dist/rooms-tasks/layout-binding-client.js +56 -0
- package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
- package/dist/rooms-tasks/layout-binding-grants.js +128 -0
- package/dist/rooms-tasks/layout-cli.d.ts +4 -0
- package/dist/rooms-tasks/layout-cli.js +107 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
- package/dist/rooms-tasks/layout-member-retirement.js +90 -0
- package/dist/rooms-tasks/layout-service.d.ts +43 -0
- package/dist/rooms-tasks/layout-service.js +203 -0
- package/dist/rooms-tasks/layout.d.ts +121 -0
- package/dist/rooms-tasks/layout.js +286 -0
- package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
- package/dist/rooms-tasks/legacy-task-member.js +116 -0
- package/dist/rooms-tasks/live-readiness.d.ts +18 -0
- package/dist/rooms-tasks/live-readiness.js +85 -0
- package/dist/rooms-tasks/member-ownership.d.ts +5 -0
- package/dist/rooms-tasks/member-ownership.js +40 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +3 -0
- package/dist/rooms-tasks/provision.js +101 -49
- package/dist/rooms-tasks/recovery.d.ts +19 -0
- package/dist/rooms-tasks/recovery.js +110 -0
- package/dist/rooms-tasks/room-state.d.ts +4 -2
- package/dist/rooms-tasks/room-state.js +47 -8
- package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
- package/dist/rooms-tasks/task-service-retirement.js +21 -0
- package/dist/rooms-tasks/task-state.d.ts +52 -8
- package/dist/rooms-tasks/task-state.js +251 -26
- package/dist/rooms-tasks/types.d.ts +40 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +35 -0
- package/dist/rooms-tasks/workspace.js +325 -0
- package/dist/runner.d.ts +33 -4
- package/dist/runner.js +410 -62
- package/dist/session/acp.d.ts +75 -2
- package/dist/session/acp.js +345 -82
- package/dist/session/arbiter.d.ts +98 -0
- package/dist/session/arbiter.js +272 -3
- package/dist/session/codex-app-server.d.ts +45 -1
- package/dist/session/codex-app-server.js +225 -32
- package/dist/session/control.d.ts +17 -3
- package/dist/session/control.js +213 -23
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-observation.d.ts +25 -0
- package/dist/session/conversation-observation.js +100 -0
- package/dist/session/conversation-replay.d.ts +50 -0
- package/dist/session/conversation-replay.js +203 -0
- package/dist/session/conversation-store.d.ts +15 -1
- package/dist/session/conversation-store.js +206 -26
- package/dist/session/conversation-types.d.ts +57 -4
- package/dist/session/prompt-batch.d.ts +79 -0
- package/dist/session/prompt-batch.js +120 -0
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +42 -1
- package/dist/setup-tunnel-preflight.d.ts +34 -0
- package/dist/setup-tunnel-preflight.js +62 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/spawn.d.ts +5 -1
- package/dist/spawn.js +40 -4
- package/dist/startup-prompt.d.ts +15 -0
- package/dist/startup-prompt.js +71 -0
- package/dist/startup-readiness.d.ts +32 -0
- package/dist/startup-readiness.js +101 -0
- package/dist/startup-telemetry.d.ts +16 -0
- package/dist/startup-telemetry.js +46 -0
- package/dist/subscriptions/cli.d.ts +91 -0
- package/dist/subscriptions/cli.js +223 -0
- package/dist/subscriptions/launch.d.ts +37 -0
- package/dist/subscriptions/launch.js +93 -0
- package/dist/subscriptions/login.d.ts +77 -0
- package/dist/subscriptions/login.js +215 -0
- package/dist/subscriptions/observed.d.ts +7 -0
- package/dist/subscriptions/observed.js +44 -0
- package/dist/subscriptions/probe.d.ts +10 -0
- package/dist/subscriptions/probe.js +73 -0
- package/dist/subscriptions/service.d.ts +102 -0
- package/dist/subscriptions/service.js +364 -0
- package/dist/subscriptions/store.d.ts +71 -0
- package/dist/subscriptions/store.js +143 -0
- package/dist/supervisor/adoption.d.ts +8 -0
- package/dist/supervisor/adoption.js +78 -0
- package/dist/supervisor/catalog.d.ts +62 -0
- package/dist/supervisor/catalog.js +254 -0
- package/dist/supervisor/fleet.d.ts +8 -0
- package/dist/supervisor/fleet.js +89 -0
- package/dist/supervisor/index.js +3 -6
- package/dist/supervisor/launchd.d.ts +1 -1
- package/dist/supervisor/launchd.js +26 -13
- package/dist/supervisor/legacy.d.ts +35 -0
- package/dist/supervisor/legacy.js +524 -0
- package/dist/supervisor/manager.d.ts +15 -0
- package/dist/supervisor/manager.js +270 -0
- package/dist/supervisor/registration-refresh.d.ts +45 -0
- package/dist/supervisor/registration-refresh.js +266 -0
- package/dist/supervisor/scope.d.ts +4 -0
- package/dist/supervisor/scope.js +10 -0
- package/dist/supervisor/systemd.d.ts +2 -2
- package/dist/supervisor/systemd.js +19 -33
- package/dist/task-supervision.d.ts +9 -0
- package/dist/task-supervision.js +145 -0
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +23 -8
- package/dist/temp-lifecycle.js +123 -66
- package/dist/temp-supervisor-recovery.d.ts +1 -1
- package/dist/temp-supervisor-recovery.js +2 -2
- package/dist/tunnel-setup-completion.d.ts +7 -0
- package/dist/tunnel-setup-completion.js +13 -0
- package/dist/tunnel-setup-input.d.ts +19 -0
- package/dist/tunnel-setup-input.js +124 -0
- package/dist/watchdog/query.d.ts +3 -1
- package/dist/watchdog/query.js +2 -1
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +65 -2
- package/dist/web/auth.js +201 -11
- package/dist/web/available-port.d.ts +2 -0
- package/dist/web/available-port.js +23 -0
- package/dist/web/control.d.ts +3 -3
- package/dist/web/control.js +10 -8
- package/dist/web/conversation-stream-codec.d.ts +23 -0
- package/dist/web/conversation-stream-codec.js +110 -0
- package/dist/web/conversation-stream.d.ts +25 -0
- package/dist/web/conversation-stream.js +231 -0
- package/dist/web/events.d.ts +2 -0
- package/dist/web/events.js +9 -0
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +32 -0
- package/dist/web/prefix-gateway.js +248 -0
- package/dist/web/resource-views.d.ts +124 -0
- package/dist/web/resource-views.js +424 -0
- package/dist/web/runtime.d.ts +3 -1
- package/dist/web/runtime.js +132 -34
- package/dist/web/server.d.ts +19 -0
- package/dist/web/server.js +802 -67
- package/dist/web/service.d.ts +11 -2
- package/dist/web/service.js +30 -4
- package/dist/web/workspace-devices.d.ts +107 -0
- package/dist/web/workspace-devices.js +277 -0
- package/dist/web/workspace-tunnel.d.ts +1 -0
- package/dist/web/workspace-tunnel.js +34 -0
- package/dist/workspace-enrollment.d.ts +83 -0
- package/dist/workspace-enrollment.js +580 -0
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/acp-files.md +146 -0
- package/docs/conversation-stream.md +196 -0
- package/docs/fleet-migration.md +54 -0
- package/docs/live-voice.md +13 -0
- package/docs/room-layouts.md +227 -0
- package/docs/self-reminders.md +374 -0
- package/docs/task-workspaces.md +225 -0
- package/docs/validation/managed-cli-permissions.md +421 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/scoped.yaml +21 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +24 -20
- package/presets/README.md +78 -0
- package/presets/brain-catalog.json +8 -2
- package/presets/fleet/agent_templates/Critic.yaml +1 -1
- package/presets/fleet/agent_templates/Developer.yaml +1 -1
- package/presets/fleet/agent_templates/Engineer.yaml +5 -0
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +4 -2
- package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +12 -11
- package/presets/fleet/roles/Critic.yaml +13 -0
- package/presets/fleet/roles/Developer.yaml +13 -0
- package/presets/fleet/roles/Engineer.yaml +53 -0
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/presets/fleet/room_templates/engineering.yaml +19 -0
- package/presets/manifest.json +2 -2
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
package/dist/web/server.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
|
|
2
|
+
import { publishFleetResourceCommit } from '../application/resource-observer.js';
|
|
3
|
+
import { stateRoot, defaultConfigPath } from '../paths.js';
|
|
4
|
+
import { conversationPreview } from '../application/conversation-history.js';
|
|
5
|
+
import { registerConversationStream } from './conversation-stream.js';
|
|
6
|
+
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
7
|
+
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
8
|
+
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { packagedPresetRoot } from '../preset-bootstrap.js';
|
|
11
|
+
import { validateCatalog } from '../init-wizard.js';
|
|
12
|
+
import { getAdapter } from '../harness/registry.js';
|
|
13
|
+
import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
|
|
14
|
+
import { readChatIdle } from '../temp-idle.js';
|
|
15
|
+
import { agentDir } from '../paths.js';
|
|
1
16
|
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { join } from 'node:path';
|
|
5
18
|
import Fastify from 'fastify';
|
|
6
|
-
import fastifyStatic from '@fastify/static';
|
|
7
19
|
import fastifyWebsocket from '@fastify/websocket';
|
|
8
20
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
9
21
|
import { FleetError, normalizeError } from '../application/errors.js';
|
|
@@ -14,6 +26,8 @@ import { FleetEventBus } from './events.js';
|
|
|
14
26
|
import { ROLE_NAME_RE } from '../config.js';
|
|
15
27
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
16
28
|
import { TaskStateError } from '../rooms-tasks/task-state.js';
|
|
29
|
+
import { isProvider } from '../subscriptions/store.js';
|
|
30
|
+
const ONBOARDING_WORK = ['coordination', 'development', 'review'];
|
|
17
31
|
const statusFor = (code) => ({
|
|
18
32
|
role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
|
|
19
33
|
idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
|
|
@@ -34,25 +48,45 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
34
48
|
app.addHook('onRequest', async (request, reply) => {
|
|
35
49
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
36
50
|
reply.header('Referrer-Policy', 'no-referrer');
|
|
37
|
-
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
38
|
-
|
|
39
|
-
reply.header('Content-Security-Policy',
|
|
40
|
-
`connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
|
|
41
|
-
`frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
|
|
51
|
+
reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
|
|
52
|
+
// Fleet serves an API only: nothing it returns may run, load or be framed as a page.
|
|
53
|
+
reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
|
|
42
54
|
if (request.url.startsWith('/api/'))
|
|
43
55
|
reply.header('Cache-Control', 'no-store');
|
|
44
|
-
|
|
45
|
-
auth.
|
|
56
|
+
if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
|
|
57
|
+
reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
|
|
58
|
+
reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
59
|
+
reply.header('Access-Control-Expose-Headers', 'X-Ours-Workspace-Auth, X-Ours-Request-Id');
|
|
60
|
+
reply.header('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-CSRF-Token,Idempotency-Key');
|
|
61
|
+
if (request.method === 'OPTIONS') {
|
|
62
|
+
auth.validateBoundary(request, false);
|
|
63
|
+
return reply.code(204).send();
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
auth.validateBoundary(request, false);
|
|
67
|
+
});
|
|
68
|
+
app.addHook('onResponse', async (request, reply) => {
|
|
69
|
+
if (reply.statusCode < 400 && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !request.url.includes('/resource-subscriptions')) {
|
|
70
|
+
publishFleetResourceCommit(services.configuration?.path ?? defaultConfigPath());
|
|
71
|
+
publishFleetResourceCommit(stateRoot());
|
|
52
72
|
}
|
|
53
73
|
});
|
|
74
|
+
app.post('/api/v1/layout-bindings/:id/control', async (request) => {
|
|
75
|
+
// Initial support is direct same-host Fleet-to-Fleet transport.
|
|
76
|
+
const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
|
|
77
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip) || forwarded)
|
|
78
|
+
throw new FleetError('forbidden', 'layout bindings require direct loopback access');
|
|
79
|
+
// Machine credentials cannot authenticate browser or lifecycle routes.
|
|
80
|
+
if (request.headers.origin)
|
|
81
|
+
auth.validateBoundary(request, true);
|
|
82
|
+
if (!services.layoutBindings)
|
|
83
|
+
throw new FleetError('capability_unavailable', 'layout bindings unavailable');
|
|
84
|
+
return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
|
|
85
|
+
});
|
|
54
86
|
app.setErrorHandler(async (error, request, reply) => {
|
|
55
|
-
|
|
87
|
+
if (error instanceof ResourceBindingError)
|
|
88
|
+
return reply.code(error.status).send({ error: { code: 'resource_binding', message: error.message } });
|
|
89
|
+
const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
|
|
56
90
|
&& error instanceof Error && 'code' in error
|
|
57
91
|
&& typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
|
|
58
92
|
const fleetError = normalizeError(privateToolParseError
|
|
@@ -61,8 +95,13 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
61
95
|
requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
|
|
62
96
|
result: 'rejected', errorCode: fleetError.code,
|
|
63
97
|
});
|
|
98
|
+
if (error instanceof WorkspaceDeviceAuthError)
|
|
99
|
+
reply.header('X-Ours-Workspace-Auth', 'rejected');
|
|
100
|
+
if (fleetError.code === 'unauthorized')
|
|
101
|
+
reply.header('X-Ours-Request-Id', request.id);
|
|
64
102
|
reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
|
|
65
103
|
});
|
|
104
|
+
registerConversationStream(app, services, auth);
|
|
66
105
|
app.post('/api/v1/auth/exchange', async (request, reply) => {
|
|
67
106
|
const { session, device } = auth.exchange(request);
|
|
68
107
|
setAuthCookies(reply, session.id, device.token, auth.secureCookies);
|
|
@@ -101,6 +140,51 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
101
140
|
const session = auth.authenticate(request);
|
|
102
141
|
return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
|
|
103
142
|
});
|
|
143
|
+
app.get('/api/v1/workspace/identity', async () => auth.workspaceIdentity());
|
|
144
|
+
app.post('/api/v1/devices/enroll', async (request, reply) => {
|
|
145
|
+
reply.header('Cache-Control', 'no-store');
|
|
146
|
+
const input = request.body;
|
|
147
|
+
if (!input)
|
|
148
|
+
throw new FleetError('invalid_request', 'device enrollment body required');
|
|
149
|
+
return auth.enrollWorkspace(request, input);
|
|
150
|
+
});
|
|
151
|
+
// Refresh credentials travel between the account server and this host; these routes refuse browsers.
|
|
152
|
+
const refreshInput = (request, name) => {
|
|
153
|
+
const value = request.body?.[name];
|
|
154
|
+
if (typeof value !== 'string')
|
|
155
|
+
throw new FleetError('invalid_request', `${name} is required`);
|
|
156
|
+
return value;
|
|
157
|
+
};
|
|
158
|
+
app.post('/api/v1/devices/refresh', async (request, reply) => {
|
|
159
|
+
reply.header('Cache-Control', 'no-store');
|
|
160
|
+
return auth.refreshWorkspace(request, refreshInput(request, 'refreshToken'));
|
|
161
|
+
});
|
|
162
|
+
app.post('/api/v1/devices/exchange', async (request, reply) => {
|
|
163
|
+
reply.header('Cache-Control', 'no-store');
|
|
164
|
+
return auth.exchangeWorkspace(request);
|
|
165
|
+
});
|
|
166
|
+
app.post('/api/v1/devices/revoke', async (request) => ({ revoked: auth.revokeWorkspaceRefresh(request, refreshInput(request, 'refreshHash')) }));
|
|
167
|
+
app.post('/api/v1/devices/link', async (request, reply) => {
|
|
168
|
+
const proof = request.body?.refreshToken;
|
|
169
|
+
if (proof !== undefined) {
|
|
170
|
+
reply.header('Cache-Control', 'no-store');
|
|
171
|
+
const { enrollment, enrollmentExpiresAt, ...credentials } = auth.linkWorkspaceDevice(request, refreshInput(request, 'refreshToken'));
|
|
172
|
+
return { ...credentials, code: { version: 1, origin: auth.origin, enrollment, workspaceId: credentials.workspaceId, expiresAt: enrollmentExpiresAt } };
|
|
173
|
+
}
|
|
174
|
+
const session = auth.authenticate(request, true);
|
|
175
|
+
// A short-lived access credential must not be able to create a lasting one.
|
|
176
|
+
if (session.rotating)
|
|
177
|
+
throw new FleetError('forbidden', 'linking another device requires this link\'s refresh credential');
|
|
178
|
+
return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
|
|
179
|
+
});
|
|
180
|
+
app.get('/api/v1/devices/current', async (request) => {
|
|
181
|
+
const session = auth.authenticate(request);
|
|
182
|
+
if (!session.id.startsWith('workspace:'))
|
|
183
|
+
throw new FleetError('resource_not_found', 'no workspace device');
|
|
184
|
+
return { deviceId: session.id.slice(10), authorizedUntil: session.absoluteExpiresAt };
|
|
185
|
+
});
|
|
186
|
+
app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
|
|
187
|
+
app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
|
|
104
188
|
app.get('/api/v1/meta', async (request) => {
|
|
105
189
|
auth.authenticate(request);
|
|
106
190
|
return {
|
|
@@ -111,6 +195,90 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
111
195
|
auditDegraded: audit.degraded,
|
|
112
196
|
};
|
|
113
197
|
});
|
|
198
|
+
let onboardingObserved;
|
|
199
|
+
app.get('/api/v1/onboarding', async (request) => {
|
|
200
|
+
const browser = auth.authenticate(request);
|
|
201
|
+
const revision = services.configuration?.read().revision;
|
|
202
|
+
// Opening an existing workspace needs configuration state, not CLI/account discovery.
|
|
203
|
+
// Keep the full report for the setup screens and older App clients.
|
|
204
|
+
if (request.query.view === 'state')
|
|
205
|
+
return { configured: onboardingConfigured() };
|
|
206
|
+
const harnesses = await Promise.all(['codex', 'claude-code'].map(async (harness) => {
|
|
207
|
+
try {
|
|
208
|
+
return { harness, ...await getAdapter(harness).checkPrereqs() };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return { harness, ok: false, checks: [{ name: 'availability', ok: false, detail: 'Harness checks unavailable' }] };
|
|
212
|
+
}
|
|
213
|
+
}));
|
|
214
|
+
const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
|
|
215
|
+
// Codex models are offered only when the runtime itself listed them for the active account.
|
|
216
|
+
// When it could not be asked, none is offered and the reason is reported.
|
|
217
|
+
const codexModels = services.codexModels ? await services.codexModels() : undefined;
|
|
218
|
+
const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
|
|
219
|
+
const payload = { 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() };
|
|
220
|
+
if (revision === services.configuration?.read().revision) {
|
|
221
|
+
onboardingObserved = { owner: browser.id, revision, payload };
|
|
222
|
+
events.publish('onboarding.changed', {});
|
|
223
|
+
}
|
|
224
|
+
return payload;
|
|
225
|
+
});
|
|
226
|
+
// First-time setup is what creates the persistent coordinator. A configuration that loads and
|
|
227
|
+
// has it is set up, whatever its brains are called; its models are then changed by editing it.
|
|
228
|
+
const onboardingConfigured = () => {
|
|
229
|
+
if (!services.configuration)
|
|
230
|
+
return false;
|
|
231
|
+
try {
|
|
232
|
+
return Boolean(services.configuration.read(false).model.agents.FleetCoordinator);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
let onboardingSetupQueue = Promise.resolve();
|
|
239
|
+
app.post('/api/v1/onboarding/setup', async (request) => {
|
|
240
|
+
auth.authenticate(request, true);
|
|
241
|
+
if (!services.configuration || !services.onboardingSetup)
|
|
242
|
+
throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
|
|
243
|
+
const requested = request.body?.models;
|
|
244
|
+
const codexModels = services.codexModels ? await services.codexModels() : undefined;
|
|
245
|
+
const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
|
|
246
|
+
.filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
|
|
247
|
+
const models = {};
|
|
248
|
+
for (const work of ONBOARDING_WORK) {
|
|
249
|
+
const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
|
|
250
|
+
if (matches.length !== 1)
|
|
251
|
+
throw new FleetError('invalid_request', `choose a supported model for ${work}`);
|
|
252
|
+
models[work] = matches[0];
|
|
253
|
+
}
|
|
254
|
+
const chosen = Object.values(models);
|
|
255
|
+
const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
|
|
256
|
+
const oneModel = new Set(chosen.map(model => `${model.harness}\0${model.model}`)).size === 1;
|
|
257
|
+
const configuration = services.configuration, setup = services.onboardingSetup;
|
|
258
|
+
// One setup at a time: the first-time check and the publication it guards must not interleave.
|
|
259
|
+
const run = onboardingSetupQueue.then(async () => {
|
|
260
|
+
if (onboardingConfigured())
|
|
261
|
+
throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
|
|
262
|
+
try {
|
|
263
|
+
await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models, reasoning: 'balanced' });
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
|
|
267
|
+
}
|
|
268
|
+
// Setup preserves files it finds, so confirm the published models are the ones this request chose.
|
|
269
|
+
// The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
|
|
270
|
+
// a preserved file of a packaged name may hold another model, session or reasoning than its name says.
|
|
271
|
+
const published = configuration.read(true).model, brains = published.brains ?? {};
|
|
272
|
+
const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
|
|
273
|
+
const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
|
|
274
|
+
for (const work of ONBOARDING_WORK)
|
|
275
|
+
if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !== 'medium')
|
|
276
|
+
throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
|
|
277
|
+
return { configured: true };
|
|
278
|
+
});
|
|
279
|
+
onboardingSetupQueue = run.catch(() => { });
|
|
280
|
+
return run;
|
|
281
|
+
});
|
|
114
282
|
app.get('/api/v1/creation-capabilities', async (request) => {
|
|
115
283
|
auth.authenticate(request);
|
|
116
284
|
return services.creation.capabilities();
|
|
@@ -133,7 +301,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
133
301
|
});
|
|
134
302
|
app.get('/api/v1/roles', async (request) => {
|
|
135
303
|
auth.authenticate(request);
|
|
136
|
-
|
|
304
|
+
const query = request.query;
|
|
305
|
+
// Unbound REST retains the existing problem-bearing inventory. Bound reads
|
|
306
|
+
// are handled by the strict resource preHandler before this route.
|
|
307
|
+
services.invalidateResourceCaches?.();
|
|
308
|
+
return { roles: await services.query.list(query.includeTemporary === 'true') };
|
|
137
309
|
});
|
|
138
310
|
const taskApi = async (fn) => {
|
|
139
311
|
try {
|
|
@@ -146,6 +318,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
146
318
|
? 'conflict' : 'invalid_request';
|
|
147
319
|
throw new FleetError(code, error.message);
|
|
148
320
|
}
|
|
321
|
+
if (error instanceof TaskStateError && 'accepted' in error && error.accepted === false)
|
|
322
|
+
return Promise.reject(Object.assign(new FleetError('invalid_request', error.message), { memberNotAccepted: true }));
|
|
149
323
|
if (error instanceof TaskStateError) {
|
|
150
324
|
throw new FleetError(error.message.startsWith('task not found:') ? 'resource_not_found' : 'conflict', error.message);
|
|
151
325
|
}
|
|
@@ -157,9 +331,152 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
157
331
|
throw new FleetError('capability_unavailable', 'task operations are unavailable');
|
|
158
332
|
return services.taskRooms;
|
|
159
333
|
};
|
|
334
|
+
const projectTasks = async (query, strict = true) => {
|
|
335
|
+
const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
|
|
336
|
+
if (query.state && query.state !== 'all' && !states.includes(query.state))
|
|
337
|
+
throw new FleetError('invalid_request', 'invalid task state filter');
|
|
338
|
+
if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
|
|
339
|
+
throw new FleetError('invalid_request', 'groupByList must be true or false');
|
|
340
|
+
if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
|
|
341
|
+
throw new FleetError('invalid_request', 'includeDeleting must be true or false');
|
|
342
|
+
const state = query.state && query.state !== 'all' ? query.state : undefined;
|
|
343
|
+
const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
|
|
344
|
+
...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
|
|
345
|
+
const api = requireTaskRooms();
|
|
346
|
+
return taskApi(() => query.groupByList === 'true'
|
|
347
|
+
? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
|
|
348
|
+
: { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
|
|
349
|
+
};
|
|
350
|
+
const project = async (descriptor, signal, owner = '') => {
|
|
351
|
+
signal.throwIfAborted();
|
|
352
|
+
services.invalidateResourceCaches?.();
|
|
353
|
+
switch (descriptor.resource) {
|
|
354
|
+
case 'task-layout':
|
|
355
|
+
try {
|
|
356
|
+
return { layout: await taskApi(() => requireTaskRooms().taskLayout(descriptor.entityId)) };
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error?.code === 'task_not_found' || error?.code === 'resource_not_found')
|
|
360
|
+
return { deleted: true, id: descriptor.entityId };
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
case 'creation-action': return services.creation.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
|
|
364
|
+
case 'action': return services.commands.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
|
|
365
|
+
case 'member-addition':
|
|
366
|
+
try {
|
|
367
|
+
return await taskApi(() => requireTaskRooms().memberAddition(descriptor.entityId, descriptor.receiptId));
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (['task_not_found', 'resource_not_found'].includes(error?.code) || error?.message === 'Member request not found')
|
|
371
|
+
return { deleted: true, id: descriptor.receiptId };
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
case 'login':
|
|
375
|
+
// A foreign login must never be delivered, including its URL/device code.
|
|
376
|
+
try {
|
|
377
|
+
return subscriptions().login(descriptor.entityId, owner).view();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (error?.code === 'resource_not_found')
|
|
381
|
+
return { deleted: true, id: descriptor.entityId };
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
case 'subscriptions': return { providers: await subscriptions().list(true) };
|
|
385
|
+
case 'onboarding-health': {
|
|
386
|
+
const revision = services.configuration?.read().revision;
|
|
387
|
+
if (onboardingObserved && onboardingObserved.revision !== revision)
|
|
388
|
+
onboardingObserved = undefined;
|
|
389
|
+
const observed = onboardingObserved?.owner === owner ? onboardingObserved : undefined;
|
|
390
|
+
return { ...observed?.payload, observationCurrent: !!observed, configured: onboardingConfigured(), providers: services.subscriptions ? await services.subscriptions.list(true) : [] };
|
|
391
|
+
}
|
|
392
|
+
case 'roles': return { roles: await services.query.list(descriptor.includeTemporary ?? false, true) };
|
|
393
|
+
case 'tasks': return projectTasks({ state: descriptor.state, list: descriptor.list, groupByList: String(descriptor.groupByList ?? false), includeDeleting: String(descriptor.includeDeleting ?? false) });
|
|
394
|
+
case 'task-lists': return { lists: requireTaskRooms().listTaskLists() };
|
|
395
|
+
case 'configuration':
|
|
396
|
+
if (!services.configuration)
|
|
397
|
+
throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
|
|
398
|
+
return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
|
|
399
|
+
case 'directory': {
|
|
400
|
+
const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
|
|
401
|
+
return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
|
|
402
|
+
}
|
|
403
|
+
case 'previews': {
|
|
404
|
+
const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
|
|
405
|
+
const previews = {};
|
|
406
|
+
for (const id of descriptor.ids ?? []) {
|
|
407
|
+
signal.throwIfAborted();
|
|
408
|
+
const role = selected.get(id), dir = role && services.repository.stateDir(role);
|
|
409
|
+
if (!role)
|
|
410
|
+
continue;
|
|
411
|
+
try {
|
|
412
|
+
previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
signal.throwIfAborted();
|
|
416
|
+
// A damaged/unreadable ledger is unavailable, not empty or removed.
|
|
417
|
+
// Keep this selected row observable so a repair can refresh it,
|
|
418
|
+
// without retiring previews for the other selected roles.
|
|
419
|
+
previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { previews };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const resources = new FleetResourceViews(services.resourceScope ?? {}, project);
|
|
427
|
+
app.addHook('preClose', async () => resources.close());
|
|
428
|
+
app.get('/api/v1/resource-events', async (request, reply) => {
|
|
429
|
+
const browser = auth.authenticate(request);
|
|
430
|
+
reply.hijack();
|
|
431
|
+
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
432
|
+
reply.raw.flushHeaders();
|
|
433
|
+
const opened = resources.open(reply.raw, browser.id, () => auth.sessionValid(browser));
|
|
434
|
+
const binding = auth.bindTransport(browser, opened.close);
|
|
435
|
+
reply.raw.once('close', () => binding());
|
|
436
|
+
});
|
|
437
|
+
app.post('/api/v1/resource-subscriptions', async (request, reply) => {
|
|
438
|
+
const browser = auth.authenticate(request, true), body = request.body;
|
|
439
|
+
const binding = resources.register(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch, body?.descriptor);
|
|
440
|
+
const retire = () => {
|
|
441
|
+
if (reply.raw.writableFinished)
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
resources.remove(browser.id, String(body.connectionId), String(body.viewId), body.subscriptionEpoch);
|
|
445
|
+
}
|
|
446
|
+
catch { /* Connection may already be closed. */ }
|
|
447
|
+
};
|
|
448
|
+
reply.raw.once('close', retire);
|
|
449
|
+
reply.raw.once('finish', () => reply.raw.off('close', retire));
|
|
450
|
+
if (reply.raw.destroyed)
|
|
451
|
+
retire();
|
|
452
|
+
return binding;
|
|
453
|
+
});
|
|
454
|
+
app.delete('/api/v1/resource-subscriptions', async (request) => {
|
|
455
|
+
const browser = auth.authenticate(request, true), body = request.body;
|
|
456
|
+
resources.remove(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch);
|
|
457
|
+
return { removed: true };
|
|
458
|
+
});
|
|
459
|
+
// Bound reads return precisely the registered initial capture, never an unrelated GET.
|
|
460
|
+
app.addHook('preHandler', async (request, reply) => {
|
|
461
|
+
if (request.method !== 'GET' || !request.headers['x-resource-connection'])
|
|
462
|
+
return;
|
|
463
|
+
const browser = auth.authenticate(request), headers = request.headers;
|
|
464
|
+
const baseline = await resources.bound(browser.id, String(headers['x-resource-connection']), String(headers['x-resource-view']), Number(headers['x-resource-epoch']), String(headers['x-resource-baseline']));
|
|
465
|
+
const expected = '/api/v1/' + baseline.descriptor.resource;
|
|
466
|
+
if (request.url.split('?')[0] !== expected)
|
|
467
|
+
throw new ResourceBindingError(409, 'resource route mismatch');
|
|
468
|
+
for (const name of ['Connection', 'View', 'Epoch', 'Baseline'])
|
|
469
|
+
reply.header('X-Resource-' + name, String(headers['x-resource-' + name.toLowerCase()]));
|
|
470
|
+
reply.header('X-Resource-Producer', baseline.producerGeneration).header('X-Resource-Revision', baseline.revision).header('X-Resource-Source-Generation', baseline.source.producerGeneration).header('X-Resource-Source-Position', baseline.source.position);
|
|
471
|
+
return reply.send(baseline.payload);
|
|
472
|
+
});
|
|
473
|
+
for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
|
|
474
|
+
app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
|
|
475
|
+
app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
|
|
476
|
+
app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(request.query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids }), new AbortController().signal); });
|
|
160
477
|
app.get('/api/v1/task-lists', async (request) => {
|
|
161
478
|
auth.authenticate(request);
|
|
162
|
-
return {
|
|
479
|
+
return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
|
|
163
480
|
});
|
|
164
481
|
app.post('/api/v1/task-lists', async (request, reply) => {
|
|
165
482
|
auth.authenticate(request, true);
|
|
@@ -182,20 +499,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
182
499
|
});
|
|
183
500
|
app.get('/api/v1/tasks', async (request) => {
|
|
184
501
|
auth.authenticate(request);
|
|
185
|
-
|
|
186
|
-
const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
|
|
187
|
-
if (query.state && query.state !== 'all' && !states.includes(query.state))
|
|
188
|
-
throw new FleetError('invalid_request', 'invalid task state filter');
|
|
189
|
-
if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
|
|
190
|
-
throw new FleetError('invalid_request', 'groupByList must be true or false');
|
|
191
|
-
if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
|
|
192
|
-
throw new FleetError('invalid_request', 'includeDeleting must be true or false');
|
|
193
|
-
const state = query.state && query.state !== 'all' ? query.state : undefined;
|
|
194
|
-
const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
|
|
195
|
-
...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
|
|
196
|
-
return taskApi(() => query.groupByList === 'true'
|
|
197
|
-
? { groups: requireTaskRooms().groupedTasks(filter) }
|
|
198
|
-
: { tasks: requireTaskRooms().listTasks(filter) });
|
|
502
|
+
return projectTasks(request.query, false);
|
|
199
503
|
});
|
|
200
504
|
app.delete('/api/v1/tasks/:id', async (request, reply) => {
|
|
201
505
|
auth.authenticate(request, true);
|
|
@@ -226,12 +530,17 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
226
530
|
const body = request.body;
|
|
227
531
|
if (typeof body?.title !== 'string' || !body.title)
|
|
228
532
|
throw new FleetError('invalid_request', 'title is required');
|
|
533
|
+
// Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
|
|
534
|
+
if (typeof body.layout !== 'string' || !body.layout)
|
|
535
|
+
throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
|
|
536
|
+
if (body.template !== undefined || body.noRoom !== undefined)
|
|
537
|
+
throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
|
|
229
538
|
const task = await taskApi(() => requireTaskRooms().createTask({
|
|
230
539
|
actor: { kind: 'local_control', surface: 'web' }, title: body.title,
|
|
231
540
|
brief: typeof body.brief === 'string' ? body.brief : undefined,
|
|
232
|
-
|
|
233
|
-
backlog: body.backlog === true, noRoom: body.noRoom === true,
|
|
541
|
+
backlog: body.backlog === true,
|
|
234
542
|
list: typeof body.list === 'string' ? body.list : undefined,
|
|
543
|
+
layout: body.layout,
|
|
235
544
|
idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
|
|
236
545
|
? request.headers['idempotency-key'] : undefined,
|
|
237
546
|
origin: { type: 'web' },
|
|
@@ -245,11 +554,162 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
245
554
|
const list = String(request.body?.list ?? '');
|
|
246
555
|
return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
|
|
247
556
|
});
|
|
557
|
+
app.patch('/api/v1/tasks/:id/description', async (request) => {
|
|
558
|
+
auth.authenticate(request, true);
|
|
559
|
+
const body = request.body;
|
|
560
|
+
if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
|
|
561
|
+
throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
|
|
562
|
+
return { task: await taskApi(() => requireTaskRooms().editTaskDescription({
|
|
563
|
+
actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
|
|
564
|
+
brief: body.brief, expectedBrief: body.expectedBrief,
|
|
565
|
+
})) };
|
|
566
|
+
});
|
|
567
|
+
app.get('/api/v1/tasks/:id', async (request) => {
|
|
568
|
+
auth.authenticate(request);
|
|
569
|
+
return taskApi(() => requireTaskRooms().getTask(request.params.id));
|
|
570
|
+
});
|
|
571
|
+
app.post('/api/v1/tasks/:id/members', async (request, reply) => {
|
|
572
|
+
auth.authenticate(request, true);
|
|
573
|
+
const body = request.body;
|
|
574
|
+
try {
|
|
575
|
+
const result = await taskApi(() => requireTaskRooms().addMember(request.params.id, body));
|
|
576
|
+
reply.code(result.state === 'running' ? 202 : 200);
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (error instanceof Error && 'memberNotAccepted' in error)
|
|
581
|
+
return reply.code(409).send({ error: { code: 'member_not_accepted', message: error.message }, accepted: false });
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
app.get('/api/v1/tasks/:id/member-additions/:requestId', async (request) => {
|
|
586
|
+
auth.authenticate(request);
|
|
587
|
+
const { id, requestId } = request.params;
|
|
588
|
+
return taskApi(() => requireTaskRooms().memberAddition(id, requestId));
|
|
589
|
+
});
|
|
590
|
+
app.patch('/api/v1/tasks/:id/layout', async (request) => {
|
|
591
|
+
const session = auth.authenticate(request, true);
|
|
592
|
+
const body = request.body;
|
|
593
|
+
const name = (v) => typeof v === 'string' && v.length > 0;
|
|
594
|
+
if (!name(body?.layout) || !(body.expectedLayout === null || name(body.expectedLayout)))
|
|
595
|
+
throw new FleetError('invalid_request', 'layout must be a layout name; expectedLayout a layout name or null');
|
|
596
|
+
const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
|
|
597
|
+
taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
|
|
598
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
|
|
599
|
+
return { task: requireTaskRooms().withLayoutRooms(task) };
|
|
600
|
+
});
|
|
601
|
+
app.get('/api/v1/tasks/:id/layout', async (request) => {
|
|
602
|
+
auth.authenticate(request);
|
|
603
|
+
return { layout: await taskApi(async () => requireTaskRooms().taskLayout(request.params.id)) };
|
|
604
|
+
});
|
|
605
|
+
for (const operation of ['open', 'close']) {
|
|
606
|
+
app.post(`/api/v1/tasks/:id/layout/rooms/:room/${operation}`, async (request, reply) => {
|
|
607
|
+
const session = auth.authenticate(request, true);
|
|
608
|
+
const { id, room } = request.params;
|
|
609
|
+
const accepted = await taskApi(() => requireTaskRooms().launchTaskLayoutOperation({
|
|
610
|
+
actor: { kind: 'local_control', surface: 'web' }, taskId: id, room,
|
|
611
|
+
operation: operation === 'open' ? 'open' : 'close-room',
|
|
612
|
+
}));
|
|
613
|
+
await audit.record({ requestId: request.id, browser: session.id, action: `task.layout.${operation}`, result: 'succeeded' });
|
|
614
|
+
reply.code(202);
|
|
615
|
+
return { operation: accepted };
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
app.post('/api/v1/tasks/:id/layout/cleanup', async (request, reply) => {
|
|
619
|
+
auth.authenticate(request, true);
|
|
620
|
+
const accepted = await taskApi(() => requireTaskRooms().retryTaskLayoutCleanup({
|
|
621
|
+
actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
|
|
622
|
+
}));
|
|
623
|
+
reply.code(202);
|
|
624
|
+
return { operation: accepted };
|
|
625
|
+
});
|
|
626
|
+
app.get('/api/v1/task-templates', async (request) => {
|
|
627
|
+
auth.authenticate(request);
|
|
628
|
+
return { templates: requireTaskRooms().listTemplates() };
|
|
629
|
+
});
|
|
630
|
+
const layouts = () => {
|
|
631
|
+
if (!services.roomLayouts)
|
|
632
|
+
throw new FleetError('capability_unavailable', 'room layout editing is unavailable');
|
|
633
|
+
return services.roomLayouts;
|
|
634
|
+
};
|
|
635
|
+
app.get('/api/v1/room-layouts', async (request) => {
|
|
636
|
+
auth.authenticate(request);
|
|
637
|
+
const list = layouts().list(), defaultLayout = layouts().defaultLayout(list);
|
|
638
|
+
return { layouts: list, ...(defaultLayout ? { default_layout: defaultLayout } : {}) };
|
|
639
|
+
});
|
|
640
|
+
app.post('/api/v1/room-layouts/validate', async (request) => {
|
|
641
|
+
auth.authenticate(request, true);
|
|
642
|
+
return layouts().validate(request.body?.definition);
|
|
643
|
+
});
|
|
644
|
+
app.put('/api/v1/room-layouts/:name', async (request) => {
|
|
645
|
+
const session = auth.authenticate(request, true);
|
|
646
|
+
const body = request.body;
|
|
647
|
+
if (typeof body?.revision !== 'string')
|
|
648
|
+
throw new FleetError('invalid_request', 'revision is required');
|
|
649
|
+
const result = await layouts().save(request.params.name, body.revision, body.definition);
|
|
650
|
+
events.publish('room_layouts.changed', { name: result.name, revision: result.revision });
|
|
651
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.save', result: 'succeeded' });
|
|
652
|
+
return result;
|
|
653
|
+
});
|
|
654
|
+
app.delete('/api/v1/room-layouts/:name', async (request) => {
|
|
655
|
+
const session = auth.authenticate(request, true);
|
|
656
|
+
const revision = request.query.revision;
|
|
657
|
+
if (typeof revision !== 'string' || !revision)
|
|
658
|
+
throw new FleetError('invalid_request', 'revision is required');
|
|
659
|
+
const result = await layouts().remove(request.params.name, revision);
|
|
660
|
+
events.publish('room_layouts.changed', { name: result.name });
|
|
661
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.delete', result: 'succeeded' });
|
|
662
|
+
return result;
|
|
663
|
+
});
|
|
664
|
+
for (const action of ['start', 'block', 'unblock', 'review', 'finish', 'cancel']) {
|
|
665
|
+
app.post(`/api/v1/tasks/:id/${action}`, async (request, reply) => {
|
|
666
|
+
auth.authenticate(request, true);
|
|
667
|
+
const taskId = request.params.id;
|
|
668
|
+
const body = (request.body ?? {});
|
|
669
|
+
const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
|
|
670
|
+
const api = requireTaskRooms();
|
|
671
|
+
return taskApi(async () => {
|
|
672
|
+
switch (action) {
|
|
673
|
+
case 'start': {
|
|
674
|
+
// A task's layout can be chosen or changed but never removed, so this check cannot go stale.
|
|
675
|
+
if (!api.getTask(taskId).task.layout)
|
|
676
|
+
throw new FleetError('conflict', 'choose a room layout for this task before starting it');
|
|
677
|
+
const task = await api.startTask(input);
|
|
678
|
+
const provisioning = await api.launchTaskProvisioning(taskId);
|
|
679
|
+
if (provisioning.kind !== 'ready')
|
|
680
|
+
reply.code(202);
|
|
681
|
+
return { task, provisioning };
|
|
682
|
+
}
|
|
683
|
+
case 'block':
|
|
684
|
+
if (typeof body.reason !== 'string' || !body.reason.trim())
|
|
685
|
+
throw new FleetError('invalid_request', 'reason is required');
|
|
686
|
+
return { task: api.blockTask({ ...input, reason: body.reason }) };
|
|
687
|
+
case 'unblock': return { task: api.unblockTask(input) };
|
|
688
|
+
case 'review': return { task: api.reviewTask(input) };
|
|
689
|
+
case 'finish':
|
|
690
|
+
case 'cancel': {
|
|
691
|
+
const plan = action === 'finish' ? await api.finishTask(input) : await api.cancelTask(input);
|
|
692
|
+
const task = plan.settlementRequired ? await api.launchTaskSettlement(taskId) : plan.task;
|
|
693
|
+
const pending = task.terminal_intent?.status === 'pending';
|
|
694
|
+
if (pending)
|
|
695
|
+
reply.code(202);
|
|
696
|
+
return { task, pending };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
app.get('/api/v1/configuration/provenance', async (request) => {
|
|
703
|
+
auth.authenticate(request);
|
|
704
|
+
if (!services.presetProvenance)
|
|
705
|
+
throw new FleetError('capability_unavailable', 'preset provenance is unavailable');
|
|
706
|
+
return { provenance: services.presetProvenance.read() };
|
|
707
|
+
});
|
|
248
708
|
app.get('/api/v1/configuration', async (request) => {
|
|
249
709
|
auth.authenticate(request);
|
|
250
710
|
if (!services.configuration)
|
|
251
711
|
throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
|
|
252
|
-
return
|
|
712
|
+
return project({ viewId: 'read', resource: 'configuration', includeDefinitions: request.query.includeDefinitions === 'true' }, new AbortController().signal);
|
|
253
713
|
});
|
|
254
714
|
app.post('/api/v1/configuration/preview', async (request) => {
|
|
255
715
|
auth.authenticate(request, true);
|
|
@@ -270,6 +730,86 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
270
730
|
});
|
|
271
731
|
return result;
|
|
272
732
|
});
|
|
733
|
+
// Claude Code / Codex subscription profiles. Responses carry account metadata
|
|
734
|
+
// only; token values never leave the CLI-owned credential files.
|
|
735
|
+
const subscriptions = () => {
|
|
736
|
+
if (!services.subscriptions)
|
|
737
|
+
throw new FleetError('capability_unavailable', 'subscription management is unavailable');
|
|
738
|
+
return services.subscriptions;
|
|
739
|
+
};
|
|
740
|
+
const provider = (raw) => {
|
|
741
|
+
if (!isProvider(raw))
|
|
742
|
+
throw new FleetError('invalid_request', 'unknown subscription provider');
|
|
743
|
+
return raw;
|
|
744
|
+
};
|
|
745
|
+
const subscriptionsChanged = () => events.publish('subscription.changed', {});
|
|
746
|
+
app.get('/api/v1/subscriptions', async (request) => {
|
|
747
|
+
auth.authenticate(request);
|
|
748
|
+
return { providers: await subscriptions().list() };
|
|
749
|
+
});
|
|
750
|
+
app.post('/api/v1/subscriptions/:provider/logins', async (request, reply) => {
|
|
751
|
+
const session = auth.authenticate(request, true);
|
|
752
|
+
const login = await subscriptions().startLogin(provider(request.params.provider), session.id);
|
|
753
|
+
await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.${request.params.provider}`, result: login.state });
|
|
754
|
+
reply.code(201);
|
|
755
|
+
return login;
|
|
756
|
+
});
|
|
757
|
+
app.get('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
|
|
758
|
+
const session = auth.authenticate(request);
|
|
759
|
+
return subscriptions().login(request.params.loginId, session.id).view();
|
|
760
|
+
});
|
|
761
|
+
app.post('/api/v1/subscriptions/:provider/logins/:loginId/code', async (request) => {
|
|
762
|
+
const session = auth.authenticate(request, true);
|
|
763
|
+
const code = request.body?.code;
|
|
764
|
+
if (typeof code !== 'string')
|
|
765
|
+
throw new FleetError('invalid_request', 'code is required');
|
|
766
|
+
const login = subscriptions().login(request.params.loginId, session.id);
|
|
767
|
+
login.submitCode(code.trim());
|
|
768
|
+
await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.code.${request.params.provider}`, result: 'submitted' });
|
|
769
|
+
return login.view();
|
|
770
|
+
});
|
|
771
|
+
app.delete('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
|
|
772
|
+
const session = auth.authenticate(request, true);
|
|
773
|
+
const login = subscriptions().login(request.params.loginId, session.id);
|
|
774
|
+
login.cancel();
|
|
775
|
+
return login.view();
|
|
776
|
+
});
|
|
777
|
+
app.post('/api/v1/subscriptions/:provider/active', async (request) => {
|
|
778
|
+
const session = auth.authenticate(request, true);
|
|
779
|
+
const profileId = request.body?.profileId;
|
|
780
|
+
if (typeof profileId !== 'string')
|
|
781
|
+
throw new FleetError('invalid_request', 'profileId is required');
|
|
782
|
+
const view = await subscriptions().setActive(provider(request.params.provider), profileId);
|
|
783
|
+
subscriptionsChanged();
|
|
784
|
+
await audit.record({ requestId: request.id, browser: session.id, action: `subscription.activate.${request.params.provider}`, result: 'succeeded' });
|
|
785
|
+
return view;
|
|
786
|
+
});
|
|
787
|
+
app.post('/api/v1/subscriptions/:provider/profiles/:profileId/check', async (request) => {
|
|
788
|
+
auth.authenticate(request, true);
|
|
789
|
+
return subscriptions().check(provider(request.params.provider), request.params.profileId);
|
|
790
|
+
});
|
|
791
|
+
app.post('/api/v1/subscriptions/claude/profiles/:profileId/probe', async (request) => {
|
|
792
|
+
const session = auth.authenticate(request, true);
|
|
793
|
+
const usage = await subscriptions().probeClaudeUsage(request.params.profileId);
|
|
794
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'subscription.usage.probe.claude', result: usage.source });
|
|
795
|
+
return { usage };
|
|
796
|
+
});
|
|
797
|
+
app.patch('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
|
|
798
|
+
auth.authenticate(request, true);
|
|
799
|
+
const label = request.body?.label;
|
|
800
|
+
if (typeof label !== 'string')
|
|
801
|
+
throw new FleetError('invalid_request', 'label is required');
|
|
802
|
+
await subscriptions().rename(provider(request.params.provider), request.params.profileId, label);
|
|
803
|
+
subscriptionsChanged();
|
|
804
|
+
return { ok: true };
|
|
805
|
+
});
|
|
806
|
+
app.delete('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
|
|
807
|
+
const session = auth.authenticate(request, true);
|
|
808
|
+
await subscriptions().remove(provider(request.params.provider), request.params.profileId);
|
|
809
|
+
subscriptionsChanged();
|
|
810
|
+
await audit.record({ requestId: request.id, browser: session.id, action: `subscription.remove.${request.params.provider}`, result: 'succeeded' });
|
|
811
|
+
return { ok: true };
|
|
812
|
+
});
|
|
273
813
|
app.get('/api/v1/topology', async (request) => {
|
|
274
814
|
auth.authenticate(request);
|
|
275
815
|
if (!services.topology)
|
|
@@ -317,6 +857,20 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
317
857
|
return result;
|
|
318
858
|
});
|
|
319
859
|
const oursTools = services.oursTools ?? new SupervisorOursTools();
|
|
860
|
+
app.get('/api/v1/roles/:id/contacts', async (request) => {
|
|
861
|
+
auth.authenticate(request);
|
|
862
|
+
const control = await services.session(request.params.id);
|
|
863
|
+
if (!control.agentContacts)
|
|
864
|
+
throw new FleetError('capability_unavailable', 'Supervisor contacts API is unavailable');
|
|
865
|
+
return control.agentContacts();
|
|
866
|
+
});
|
|
867
|
+
app.get('/api/v1/roles/:id/messages', async (request) => {
|
|
868
|
+
auth.authenticate(request);
|
|
869
|
+
const control = await services.session(request.params.id);
|
|
870
|
+
if (!control.agentHistory)
|
|
871
|
+
throw new FleetError('capability_unavailable', 'Supervisor history API is unavailable');
|
|
872
|
+
return control.agentHistory({ peer_cid: request.query.peer_cid, ...(request.query.limit !== undefined ? { limit: Number(request.query.limit) } : {}), ...(request.query.before_seq !== undefined ? { before_seq: Number(request.query.before_seq) } : {}) });
|
|
873
|
+
});
|
|
320
874
|
app.get('/api/v1/roles/:id/ours/tools', async (request) => {
|
|
321
875
|
auth.authenticate(request);
|
|
322
876
|
return oursTools.list(request.params.id);
|
|
@@ -330,7 +884,13 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
330
884
|
});
|
|
331
885
|
app.get('/api/v1/roles/:id', async (request) => {
|
|
332
886
|
auth.authenticate(request);
|
|
333
|
-
|
|
887
|
+
const detail = await services.query.detail(request.params.id);
|
|
888
|
+
return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
|
|
889
|
+
});
|
|
890
|
+
app.get('/api/v1/roles/:id/voice-capabilities', async (request) => {
|
|
891
|
+
auth.authenticate(request);
|
|
892
|
+
const descriptor = await (await services.session(request.params.id)).describe();
|
|
893
|
+
return { supported: ['generation_bound_prompts', 'idle_bound_prompts', 'targeted_voice_interrupt'].every(f => descriptor.features.includes(f)) };
|
|
334
894
|
});
|
|
335
895
|
app.get('/api/v1/roles/:id/removal-preview', async (request) => {
|
|
336
896
|
auth.authenticate(request);
|
|
@@ -365,15 +925,103 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
365
925
|
const control = await services.session(request.params.id);
|
|
366
926
|
if (!control.conversationPage)
|
|
367
927
|
throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
928
|
+
const limit = request.query.limit ? Number(request.query.limit) : undefined;
|
|
929
|
+
// `latest` / `before` page backwards from the newest event; `after` pages forward for live updates.
|
|
930
|
+
const backward = request.query.latest === '1' || request.query.before !== undefined;
|
|
931
|
+
if (backward && request.query.after !== undefined)
|
|
932
|
+
throw new FleetError('invalid_request', 'after cannot be combined with latest or before');
|
|
933
|
+
if (backward && !control.conversationTail)
|
|
934
|
+
throw new FleetError('capability_unavailable', 'this role cannot page its conversation backwards');
|
|
935
|
+
const role = await services.repository.get(request.params.id);
|
|
936
|
+
const stateDir = role ? services.repository.stateDir(role) : undefined;
|
|
937
|
+
const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
|
|
938
|
+
if (backward) {
|
|
939
|
+
const page = await control.conversationTail({ before: request.query.before, limit });
|
|
940
|
+
return { ...page, events: page.events.map(present), context: page.context.map(present) };
|
|
941
|
+
}
|
|
942
|
+
const page = await control.conversationPage({ after: request.query.after, limit });
|
|
943
|
+
return { ...page, events: page.events.map(present) };
|
|
944
|
+
});
|
|
945
|
+
app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
|
|
946
|
+
auth.authenticate(request);
|
|
947
|
+
const role = await services.repository.get(request.params.id);
|
|
948
|
+
if (!role)
|
|
949
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
950
|
+
const stateDir = services.repository.stateDir(role);
|
|
951
|
+
if (!stateDir)
|
|
952
|
+
throw new FleetError('resource_not_found', 'Agent file not found');
|
|
953
|
+
try {
|
|
954
|
+
const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
|
|
955
|
+
if (!await conversationHasAttachment(stateDir, record))
|
|
956
|
+
throw Error('ARTIFACT_NOT_PUBLISHED');
|
|
957
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
958
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
959
|
+
reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
960
|
+
reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
|
|
961
|
+
return reply.type('application/octet-stream').send(bytes);
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
throw new FleetError('resource_not_found', 'Agent file unavailable');
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
|
|
968
|
+
auth.authenticate(request);
|
|
969
|
+
const role = await services.repository.get(request.params.id);
|
|
970
|
+
if (!role)
|
|
971
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
972
|
+
const stateDir = services.repository.stateDir(role);
|
|
973
|
+
if (!stateDir)
|
|
974
|
+
throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
|
|
975
|
+
const { record, bytes } = readAgentAttachment(stateDir, request.params.attachmentId);
|
|
976
|
+
const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(record.mimeType);
|
|
977
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
978
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
979
|
+
reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
980
|
+
reply.header('Content-Disposition', `${image ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
|
|
981
|
+
return reply.type(image ? record.mimeType : 'application/octet-stream').send(bytes);
|
|
982
|
+
});
|
|
983
|
+
app.post('/api/v1/roles/:id/attachments', { bodyLimit: MAX_ATTACHMENT_BODY }, async (request) => {
|
|
984
|
+
auth.authenticate(request, true);
|
|
985
|
+
const role = await services.repository.get(request.params.id);
|
|
986
|
+
if (!role)
|
|
987
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
988
|
+
const stateDir = services.repository.stateDir(role);
|
|
989
|
+
if (!stateDir)
|
|
990
|
+
throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
|
|
991
|
+
const body = request.body;
|
|
992
|
+
if (typeof body?.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
|
|
993
|
+
throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
|
|
994
|
+
const control = await services.session(request.params.id);
|
|
995
|
+
if (!control.conversationPage || !control.submitPromptV2)
|
|
996
|
+
throw new FleetError('capability_unavailable', 'Session-bound attachments are unavailable');
|
|
997
|
+
const page = await control.conversationPage({ limit: 1 });
|
|
998
|
+
if (page.snapshot.sessionGeneration !== body.expectedSessionGeneration)
|
|
999
|
+
throw new FleetError('stale_state', 'Agent session changed. Select the files again.');
|
|
1000
|
+
return storeAgentAttachment(stateDir, body.expectedSessionGeneration, request.body);
|
|
372
1001
|
});
|
|
373
1002
|
app.post('/api/v1/roles/:id/input', async (request, reply) => {
|
|
374
1003
|
const session = auth.authenticate(request, true);
|
|
375
1004
|
const body = request.body;
|
|
376
|
-
|
|
1005
|
+
if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
|
|
1006
|
+
throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
|
|
1007
|
+
if (body?.requireIdle !== undefined && typeof body.requireIdle !== 'boolean')
|
|
1008
|
+
throw new FleetError('invalid_request', 'requireIdle must be boolean');
|
|
1009
|
+
let text = String(body?.text ?? '');
|
|
1010
|
+
if (body?.attachments !== undefined) {
|
|
1011
|
+
if (typeof body.commandId !== 'string' || !body.commandId.trim())
|
|
1012
|
+
throw new FleetError('invalid_request', 'commandId is required for attachments');
|
|
1013
|
+
if (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
|
|
1014
|
+
throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
|
|
1015
|
+
const role = await services.repository.get(request.params.id);
|
|
1016
|
+
if (!role)
|
|
1017
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
1018
|
+
const stateDir = services.repository.stateDir(role);
|
|
1019
|
+
if (!stateDir)
|
|
1020
|
+
throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
|
|
1021
|
+
const originalText = text;
|
|
1022
|
+
text = attachmentPrompt(stateDir, body.expectedSessionGeneration, body.attachments, text);
|
|
1023
|
+
prepareAttachmentPresentation(stateDir, body.expectedSessionGeneration, body.commandId, body.attachments, originalText, text);
|
|
1024
|
+
}
|
|
377
1025
|
const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
|
|
378
1026
|
? body.commandId : undefined;
|
|
379
1027
|
const control = await services.session(request.params.id);
|
|
@@ -384,6 +1032,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
384
1032
|
const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
|
|
385
1033
|
const receipt = await control.submitPromptV2({
|
|
386
1034
|
commandId: admittedCommandId, text, source: 'owner_admin_console',
|
|
1035
|
+
expectedSessionGeneration: body.expectedSessionGeneration, requireIdle: body.requireIdle === true,
|
|
387
1036
|
actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
|
|
388
1037
|
});
|
|
389
1038
|
await audit.record({
|
|
@@ -395,6 +1044,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
395
1044
|
reply.code(202);
|
|
396
1045
|
return receipt;
|
|
397
1046
|
}
|
|
1047
|
+
if (body.expectedSessionGeneration !== undefined || body.requireIdle)
|
|
1048
|
+
throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
|
|
398
1049
|
const receipt = await control.sendText(text);
|
|
399
1050
|
await audit.record({
|
|
400
1051
|
requestId: request.id, browser: session.id, roleId: request.params.id,
|
|
@@ -410,6 +1061,16 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
410
1061
|
const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
|
|
411
1062
|
? body.commandId : undefined;
|
|
412
1063
|
const control = await services.session(request.params.id);
|
|
1064
|
+
if (body?.expectedSessionGeneration !== undefined || body?.promptId !== undefined) {
|
|
1065
|
+
if (!commandId || typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim() || typeof body.promptId !== 'string' || !body.promptId.trim())
|
|
1066
|
+
throw new FleetError('invalid_request', 'Targeted interrupt requires commandId, expectedSessionGeneration and promptId');
|
|
1067
|
+
if (!control.interruptPromptV2)
|
|
1068
|
+
throw new FleetError('capability_unavailable', 'Targeted voice interrupt is unavailable');
|
|
1069
|
+
const receipt = await control.interruptPromptV2({ commandId, expectedSessionGeneration: body.expectedSessionGeneration, promptId: body.promptId });
|
|
1070
|
+
await audit.record({ requestId: request.id, browser: session.id, roleId: request.params.id, action: 'session.interrupt_voice', result: 'accepted' });
|
|
1071
|
+
reply.code(202);
|
|
1072
|
+
return receipt;
|
|
1073
|
+
}
|
|
413
1074
|
if (commandId && control.interruptV2) {
|
|
414
1075
|
const receipt = await control.interruptV2(commandId);
|
|
415
1076
|
await audit.record({
|
|
@@ -460,10 +1121,22 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
460
1121
|
const body = request.body;
|
|
461
1122
|
if (!body.action || !['start', 'stop', 'restart_resume', 'restart_fresh'].includes(body.action))
|
|
462
1123
|
throw new FleetError('invalid_request', 'invalid lifecycle action');
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
1124
|
+
let receipt;
|
|
1125
|
+
try {
|
|
1126
|
+
receipt = await services.commands.execute({
|
|
1127
|
+
roleId: request.params.id, action: body.action,
|
|
1128
|
+
actionId: body.actionId, confirmation: body.confirmation,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
// An existing receipt always wins: a conflicting/repeated action is not a
|
|
1133
|
+
// proof that the original operation was never admitted.
|
|
1134
|
+
if (typeof body.actionId === 'string' && !services.commands.get(body.actionId)) {
|
|
1135
|
+
const normalized = normalizeError(error);
|
|
1136
|
+
return reply.code(409).send({ error: { code: 'action_not_accepted', message: normalized.message }, accepted: false });
|
|
1137
|
+
}
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
467
1140
|
events.publish('action.changed', receipt, request.params.id);
|
|
468
1141
|
reply.code(202);
|
|
469
1142
|
return receipt;
|
|
@@ -482,6 +1155,75 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
482
1155
|
throw new FleetError('role_not_found', 'creation action not found');
|
|
483
1156
|
return action;
|
|
484
1157
|
});
|
|
1158
|
+
// A fetch stream works through the same authenticated workspace transport as
|
|
1159
|
+
// ordinary requests, and owns one ACP controller until its browser disconnects.
|
|
1160
|
+
app.post('/api/v1/roles/:id/live-presence', async (request, reply) => {
|
|
1161
|
+
const browser = auth.authenticate(request, true);
|
|
1162
|
+
const generation = request.body?.expectedSessionGeneration;
|
|
1163
|
+
if (typeof generation !== 'string' || !generation.trim())
|
|
1164
|
+
throw new FleetError('invalid_request', 'expectedSessionGeneration is required');
|
|
1165
|
+
const control = await services.session(request.params.id);
|
|
1166
|
+
if (!control.followConversation || !control.conversationPage)
|
|
1167
|
+
throw new FleetError('capability_unavailable', 'Live voice requires a conversation controller');
|
|
1168
|
+
const page = await control.conversationPage({ limit: 1 });
|
|
1169
|
+
if (page.snapshot.sessionGeneration !== generation)
|
|
1170
|
+
throw new FleetError('stale_state', 'Agent session changed. Start live voice again.');
|
|
1171
|
+
// Session/page discovery can await I/O; recheck revocation before attaching.
|
|
1172
|
+
auth.authenticate(request, true);
|
|
1173
|
+
if (reply.raw.destroyed)
|
|
1174
|
+
return;
|
|
1175
|
+
reply.hijack();
|
|
1176
|
+
for (const [name, value] of Object.entries(reply.getHeaders()))
|
|
1177
|
+
if (value !== undefined)
|
|
1178
|
+
reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
|
|
1179
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
|
|
1180
|
+
let ended = false;
|
|
1181
|
+
let follow;
|
|
1182
|
+
let heartbeat;
|
|
1183
|
+
let unbind;
|
|
1184
|
+
const close = () => { if (ended)
|
|
1185
|
+
return; ended = true; unbind?.(); if (heartbeat)
|
|
1186
|
+
clearInterval(heartbeat); follow?.close(); reply.raw.end(); };
|
|
1187
|
+
const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
|
|
1188
|
+
close(); };
|
|
1189
|
+
reply.raw.once('close', close);
|
|
1190
|
+
// A voice session outlives one access credential; its page renews this stream by name.
|
|
1191
|
+
const renewal = browser.rotating ? randomBytes(32).toString('base64url') : undefined;
|
|
1192
|
+
unbind = auth.bindTransport(browser, close, renewal);
|
|
1193
|
+
try {
|
|
1194
|
+
follow = await control.followConversation({
|
|
1195
|
+
after: page.nextCursor,
|
|
1196
|
+
onPage: current => { if (current.snapshot.sessionGeneration !== generation) {
|
|
1197
|
+
write({ error: 'Agent session changed. Start live voice again.' });
|
|
1198
|
+
close();
|
|
1199
|
+
}
|
|
1200
|
+
else
|
|
1201
|
+
write({ ready: true, ...(renewal ? { renewal } : {}) }); },
|
|
1202
|
+
onEvent: event => { if (event.sessionGeneration !== generation) {
|
|
1203
|
+
write({ error: 'Agent session changed. Start live voice again.' });
|
|
1204
|
+
close();
|
|
1205
|
+
} },
|
|
1206
|
+
onClose: () => close(),
|
|
1207
|
+
});
|
|
1208
|
+
if (ended)
|
|
1209
|
+
follow.close();
|
|
1210
|
+
// A renewable stream is ended by its own deadline and by revocation, not by the first credential's expiry.
|
|
1211
|
+
else
|
|
1212
|
+
heartbeat = setInterval(() => { try {
|
|
1213
|
+
if (!renewal)
|
|
1214
|
+
auth.authenticate(request);
|
|
1215
|
+
write({ heartbeat: true });
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
close();
|
|
1219
|
+
} }, 10_000);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
write({ error: 'Agent controller connection failed.' });
|
|
1223
|
+
close();
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
app.post('/api/v1/transports/:id/renew', async (request) => auth.renewTransport(request, request.params.id));
|
|
485
1227
|
app.post('/api/v1/ws-tickets', async (request) => {
|
|
486
1228
|
const body = request.body;
|
|
487
1229
|
if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
|
|
@@ -516,7 +1258,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
516
1258
|
requireSubprotocol(request, 'ours-fleet-events.v1');
|
|
517
1259
|
authorizeSocket(socket, request, async (hello) => {
|
|
518
1260
|
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
|
|
519
|
-
auth.bindSocket(session
|
|
1261
|
+
auth.bindSocket(session, socket);
|
|
520
1262
|
const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
|
|
521
1263
|
socket.on('close', detach);
|
|
522
1264
|
socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
|
|
@@ -526,7 +1268,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
526
1268
|
requireSubprotocol(request, 'ours-fleet-conversation.v1');
|
|
527
1269
|
authorizeSocket(socket, request, async (hello) => {
|
|
528
1270
|
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'conversation', request.params.id);
|
|
529
|
-
auth.bindSocket(session
|
|
1271
|
+
auth.bindSocket(session, socket);
|
|
530
1272
|
const control = await services.session(request.params.id);
|
|
531
1273
|
if (!control.followConversation)
|
|
532
1274
|
throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
|
|
@@ -551,6 +1293,9 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
551
1293
|
}
|
|
552
1294
|
socket.send(JSON.stringify(payload));
|
|
553
1295
|
};
|
|
1296
|
+
const role = await services.repository.get(request.params.id);
|
|
1297
|
+
const stateDir = role ? services.repository.stateDir(role) : undefined;
|
|
1298
|
+
const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
|
|
554
1299
|
const follow = await control.followConversation({
|
|
555
1300
|
after,
|
|
556
1301
|
onPage: page => {
|
|
@@ -561,11 +1306,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
561
1306
|
lastCursor: page.nextCursor ?? after,
|
|
562
1307
|
});
|
|
563
1308
|
if (page.events.length)
|
|
564
|
-
guardedSend({ type: 'events', events: page.events });
|
|
1309
|
+
guardedSend({ type: 'events', events: page.events.map(present) });
|
|
565
1310
|
if (page.hasMore)
|
|
566
1311
|
guardedSend({ type: 'resync.required' });
|
|
567
1312
|
},
|
|
568
|
-
onEvent: event => guardedSend({ type: 'events', events: [event] }),
|
|
1313
|
+
onEvent: event => guardedSend({ type: 'events', events: [present(event)] }),
|
|
569
1314
|
onClose: reason => {
|
|
570
1315
|
if (socket.readyState === socket.OPEN)
|
|
571
1316
|
socket.close(4409, (reason ?? 'conversation stream ended').slice(0, 120));
|
|
@@ -578,15 +1323,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
578
1323
|
socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
|
|
579
1324
|
});
|
|
580
1325
|
});
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
|
|
584
|
-
app.setNotFoundHandler((request, reply) => {
|
|
585
|
-
if (request.url.startsWith('/api/'))
|
|
586
|
-
return reply.code(404).send({ error: 'not found' });
|
|
587
|
-
return reply.sendFile('index.html');
|
|
588
|
-
});
|
|
589
|
-
}
|
|
1326
|
+
// Fleet has no pages of its own; every screen belongs to the App.
|
|
1327
|
+
app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'not found' }));
|
|
590
1328
|
return {
|
|
591
1329
|
app, auth, audit, events,
|
|
592
1330
|
async close() {
|
|
@@ -599,17 +1337,17 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
599
1337
|
function setAuthCookies(reply, session, device, secure = false) {
|
|
600
1338
|
const suffix = secure ? '; Secure' : '';
|
|
601
1339
|
reply.header('Set-Cookie', [
|
|
602
|
-
`ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path
|
|
603
|
-
`ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path
|
|
1340
|
+
`ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${suffix}`,
|
|
1341
|
+
`ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000${suffix}`,
|
|
604
1342
|
]);
|
|
605
1343
|
}
|
|
606
1344
|
function setSessionCookie(reply, session, secure = false) {
|
|
607
|
-
reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path
|
|
1345
|
+
reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${secure ? '; Secure' : ''}`);
|
|
608
1346
|
}
|
|
609
1347
|
function clearAuthCookies(reply) {
|
|
610
1348
|
reply.header('Set-Cookie', [
|
|
611
|
-
'ofs_session=; HttpOnly; SameSite=Strict; Path
|
|
612
|
-
'ofs_device=; HttpOnly; SameSite=Strict; Path
|
|
1349
|
+
'ofs_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
|
|
1350
|
+
'ofs_device=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
|
|
613
1351
|
]);
|
|
614
1352
|
}
|
|
615
1353
|
function promoteRequest(body) {
|
|
@@ -623,9 +1361,6 @@ function promoteRequest(body) {
|
|
|
623
1361
|
};
|
|
624
1362
|
}
|
|
625
1363
|
function cryptoRandomId() { return randomBytes(12).toString('hex'); }
|
|
626
|
-
function escapeHtml(value) {
|
|
627
|
-
return value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
628
|
-
}
|
|
629
1364
|
function requireSubprotocol(request, expected) {
|
|
630
1365
|
const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
|
|
631
1366
|
.split(',').map(value => value.trim());
|