@ours.network/fleet 1.2.0-nightly.6 → 1.2.0-nightly.60
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 +287 -65
- package/dist/account-origin.d.ts +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/agent-ours/bridge.js +35 -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 +4 -0
- package/dist/agent-ours/mcp-endpoint.js +5 -0
- package/dist/agent-ours/runtime.d.ts +9 -1
- package/dist/agent-ours/runtime.js +50 -2
- package/dist/agent-ours/service.d.ts +21 -1
- package/dist/agent-ours/service.js +94 -14
- package/dist/application/codex-models.d.ts +15 -0
- package/dist/application/codex-models.js +56 -0
- package/dist/application/conversation-history.d.ts +27 -0
- package/dist/application/conversation-history.js +212 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- 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/role-creation-service.js +3 -2
- package/dist/application/role-removal-service.js +9 -1
- package/dist/application/role-repository.js +19 -2
- 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 +30 -0
- package/dist/application/session-control.js +72 -3
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +115 -0
- package/dist/application/task-layouts.d.ts +91 -0
- package/dist/application/task-layouts.js +259 -0
- package/dist/application/task-room-service.d.ts +99 -3
- package/dist/application/task-room-service.js +285 -32
- package/dist/application/types.d.ts +2 -0
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/briefing.d.ts +3 -1
- package/dist/briefing.js +70 -30
- package/dist/build-info.json +7 -4
- package/dist/capabilities.d.ts +6 -1
- package/dist/capabilities.js +8 -0
- package/dist/cli.js +407 -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 +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +123 -19
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +55 -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 +26 -9
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +1 -1
- package/dist/harness/claude-code.js +20 -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 +23 -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 +8 -34
- package/dist/monitor.js +89 -152
- package/dist/notifications/fleet-producer.d.ts +19 -0
- package/dist/notifications/fleet-producer.js +208 -0
- package/dist/notifications/outbox.d.ts +46 -0
- package/dist/notifications/outbox.js +158 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/notifications/task-producer.d.ts +18 -0
- package/dist/notifications/task-producer.js +67 -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 +25 -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 +83 -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 +2 -4
- package/dist/rooms-tasks/cowork-adapter.js +7 -121
- package/dist/rooms-tasks/cowork-http.js +29 -1
- 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 +285 -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 +51 -8
- package/dist/rooms-tasks/task-state.js +245 -25
- 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 +25 -3
- package/dist/runner.js +291 -49
- package/dist/session/acp.d.ts +53 -1
- package/dist/session/acp.js +216 -40
- package/dist/session/arbiter.d.ts +11 -0
- package/dist/session/arbiter.js +43 -2
- package/dist/session/codex-app-server.d.ts +32 -0
- package/dist/session/codex-app-server.js +137 -23
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +68 -3
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-store.d.ts +1 -1
- package/dist/session/conversation-store.js +7 -11
- package/dist/session/conversation-types.d.ts +28 -3
- package/dist/session/prompt-batch.d.ts +63 -0
- package/dist/session/prompt-batch.js +86 -0
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +14 -0
- 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 +4 -0
- package/dist/startup-prompt.js +36 -0
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -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 +75 -0
- package/dist/subscriptions/login.js +182 -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 +140 -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/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 +113 -65
- 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/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +63 -2
- package/dist/web/auth.js +191 -10
- 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/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 +238 -0
- package/dist/web/runtime.d.ts +3 -1
- package/dist/web/runtime.js +116 -31
- package/dist/web/server.d.ts +16 -0
- package/dist/web/server.js +654 -55
- 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 +82 -0
- package/dist/workspace-enrollment.js +566 -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/fleet-migration.md +54 -0
- package/docs/live-voice.md +13 -0
- package/docs/room-layouts.md +227 -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 +20 -19
- 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 +1 -1
- 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 +34 -11
- package/presets/fleet/roles/Engineer.yaml +40 -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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { FleetError } from '../application/errors.js';
|
|
5
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
6
|
+
export const MAX_ATTACHMENT_BODY = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 4096;
|
|
7
|
+
function fail(message) { throw new FleetError('invalid_request', message); }
|
|
8
|
+
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
9
|
+
function directory(path, create = false, privateStorage = true) {
|
|
10
|
+
if (create && !existsSync(path))
|
|
11
|
+
mkdirSync(path, { mode: 0o700 });
|
|
12
|
+
const stat = lstatSync(path);
|
|
13
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & (privateStorage ? 0o022 : 0o002)))
|
|
14
|
+
fail('Attachment directory is not a private owned directory');
|
|
15
|
+
}
|
|
16
|
+
function read(path, max) {
|
|
17
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
18
|
+
try {
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.size > max)
|
|
21
|
+
fail('Attachment is not a bounded owned regular file');
|
|
22
|
+
return readFileSync(fd);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
closeSync(fd);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function immutable(path, bytes) {
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(path, bytes, { flag: 'wx', mode: 0o400 });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'EEXIST')
|
|
34
|
+
throw error;
|
|
35
|
+
if (!read(path, bytes.length).equals(bytes))
|
|
36
|
+
fail('Attachment content changed');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function filename(name) {
|
|
40
|
+
const chars = Array.from(name.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_'));
|
|
41
|
+
while (Buffer.byteLength(chars.join('')) > 120)
|
|
42
|
+
chars.pop();
|
|
43
|
+
return chars.join('') || 'file';
|
|
44
|
+
}
|
|
45
|
+
export function storeAgentAttachment(stateDir, generation, input) {
|
|
46
|
+
if (!input || typeof input !== 'object')
|
|
47
|
+
fail('File data is required');
|
|
48
|
+
const body = input;
|
|
49
|
+
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 240 || /[\x00-\x1f]/u.test(body.name))
|
|
50
|
+
fail('A file name of 1–240 characters is required');
|
|
51
|
+
if (typeof body.mimeType !== 'string' || !/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(body.mimeType))
|
|
52
|
+
fail('A valid media type is required');
|
|
53
|
+
if (typeof body.data !== 'string' || body.data.length > Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 || /[^A-Za-z0-9+/=]/.test(body.data))
|
|
54
|
+
fail('Invalid base64 file data or file exceeds 20 MiB');
|
|
55
|
+
const bytes = Buffer.from(body.data, 'base64');
|
|
56
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES || bytes.toString('base64') !== body.data)
|
|
57
|
+
fail('Invalid file data or file exceeds 20 MiB');
|
|
58
|
+
const sha256 = digest(bytes), name = body.name, mimeType = body.mimeType;
|
|
59
|
+
const id = digest(JSON.stringify({ generation, name, mimeType, sha256 }));
|
|
60
|
+
const record = { id, name, mimeType, size: bytes.length, sha256, generation };
|
|
61
|
+
// Fleet runtime state directories may use the account group (0775). Upload storage remains private.
|
|
62
|
+
directory(stateDir, false, false);
|
|
63
|
+
const root = join(stateDir, 'web-attachments');
|
|
64
|
+
directory(root, true);
|
|
65
|
+
const dir = join(root, id);
|
|
66
|
+
directory(dir, true);
|
|
67
|
+
immutable(join(dir, 'content-' + filename(name)), bytes);
|
|
68
|
+
immutable(join(dir, 'metadata.json'), Buffer.from(JSON.stringify(record)));
|
|
69
|
+
return record;
|
|
70
|
+
}
|
|
71
|
+
export function attachmentPrompt(stateDir, generation, ids, text) {
|
|
72
|
+
if (!Array.isArray(ids) || ids.length < 1 || ids.length > 5 || new Set(ids).size !== ids.length)
|
|
73
|
+
fail('Select between 1 and 5 distinct attachments');
|
|
74
|
+
// Fleet runtime state directories may use the account group (0775). Upload storage remains private.
|
|
75
|
+
directory(stateDir, false, false);
|
|
76
|
+
const root = join(stateDir, 'web-attachments');
|
|
77
|
+
directory(root);
|
|
78
|
+
const files = ids.map(id => {
|
|
79
|
+
if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id))
|
|
80
|
+
fail('Invalid attachment ID');
|
|
81
|
+
const dir = join(root, id);
|
|
82
|
+
directory(dir);
|
|
83
|
+
const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
|
|
84
|
+
if (record.id !== id || record.generation !== generation)
|
|
85
|
+
throw new FleetError('stale_state', 'Attachment belongs to a different agent session');
|
|
86
|
+
const file = join(dir, 'content-' + filename(record.name)), bytes = read(file, MAX_ATTACHMENT_BYTES);
|
|
87
|
+
if (bytes.length !== record.size || digest(bytes) !== record.sha256 || digest(JSON.stringify({ generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
|
|
88
|
+
fail('Attachment content changed');
|
|
89
|
+
return { name: record.name, mimeType: record.mimeType, size: record.size, sha256: record.sha256, path: file };
|
|
90
|
+
});
|
|
91
|
+
return `${text}${text ? '\n\n' : ''}Attached files (available on this host; open the files to inspect their contents, and use the image viewer for images):\n${JSON.stringify(files, null, 2)}`;
|
|
92
|
+
}
|
|
93
|
+
/** Authenticated content access reuses the same ownership and integrity checks as admission. */
|
|
94
|
+
function attachmentMetadata(stateDir, id) {
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
96
|
+
fail('Invalid attachment ID');
|
|
97
|
+
directory(stateDir, false, false);
|
|
98
|
+
const root = join(stateDir, 'web-attachments');
|
|
99
|
+
directory(root);
|
|
100
|
+
const dir = join(root, id);
|
|
101
|
+
directory(dir);
|
|
102
|
+
const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
|
|
103
|
+
if (record.id !== id || typeof record.name !== 'string' || typeof record.mimeType !== 'string')
|
|
104
|
+
fail('Invalid attachment metadata');
|
|
105
|
+
if (digest(JSON.stringify({ generation: record.generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
|
|
106
|
+
fail('Attachment metadata changed');
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
export function readAgentAttachment(stateDir, id) {
|
|
110
|
+
let record;
|
|
111
|
+
try {
|
|
112
|
+
record = attachmentMetadata(stateDir, id);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code === 'ENOENT')
|
|
116
|
+
throw new FleetError('resource_not_found', 'Attachment not found');
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const bytes = read(join(stateDir, 'web-attachments', id, 'content-' + filename(record.name)), MAX_ATTACHMENT_BYTES);
|
|
120
|
+
if (bytes.length !== record.size || digest(bytes) !== record.sha256)
|
|
121
|
+
fail('Attachment content changed');
|
|
122
|
+
return { record, bytes };
|
|
123
|
+
}
|
|
124
|
+
/** Written before admission so a lost HTTP response cannot lose the visible message. */
|
|
125
|
+
export function storeAttachmentPresentation(stateDir, value) {
|
|
126
|
+
directory(stateDir, false, false);
|
|
127
|
+
const root = join(stateDir, 'web-attachment-messages');
|
|
128
|
+
directory(root, true);
|
|
129
|
+
immutable(join(root, digest(JSON.stringify([value.generation, value.commandId])) + '.json'), Buffer.from(JSON.stringify(value)));
|
|
130
|
+
}
|
|
131
|
+
export function prepareAttachmentPresentation(stateDir, generation, commandId, ids, text, transport) {
|
|
132
|
+
storeAttachmentPresentation(stateDir, { generation, commandId, ids, text, transportDigest: digest(transport) });
|
|
133
|
+
}
|
|
134
|
+
/** Never infer attachments by stripping user text or interpreting a marker. */
|
|
135
|
+
export function presentAttachmentEvent(stateDir, roleId, event) {
|
|
136
|
+
const e = event;
|
|
137
|
+
if (!stateDir || e.kind !== 'prompt.admitted' || e.source !== 'owner_admin_console' || !e.commandId || e.payload?.text?.redacted)
|
|
138
|
+
return event;
|
|
139
|
+
const root = join(stateDir, 'web-attachment-messages');
|
|
140
|
+
if (!existsSync(root))
|
|
141
|
+
return event;
|
|
142
|
+
const path = join(root, digest(JSON.stringify([e.sessionGeneration, e.commandId])) + '.json');
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return event;
|
|
145
|
+
try {
|
|
146
|
+
directory(stateDir, false, false);
|
|
147
|
+
directory(root);
|
|
148
|
+
const value = JSON.parse(read(path, 1024 * 1024).toString());
|
|
149
|
+
if (value.generation !== e.sessionGeneration || value.commandId !== e.commandId || value.transportDigest !== digest(e.payload.text.text))
|
|
150
|
+
return event;
|
|
151
|
+
const attachments = value.ids.map(id => {
|
|
152
|
+
const record = attachmentMetadata(stateDir, id);
|
|
153
|
+
if (record.generation !== value.generation)
|
|
154
|
+
fail('Attachment session changed');
|
|
155
|
+
return { id, name: record.name, mimeType: record.mimeType, size: record.size, url: `/api/v1/roles/${encodeURIComponent(roleId)}/attachments/${id}` };
|
|
156
|
+
});
|
|
157
|
+
return { ...e, payload: { ...e.payload, text: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, displayText: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, attachments } };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return event;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WorkspaceDeviceStore } from './workspace-devices.js';
|
|
1
2
|
import type { FastifyRequest } from 'fastify';
|
|
2
3
|
import type { WebSocket } from 'ws';
|
|
3
4
|
import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
|
|
@@ -8,6 +9,8 @@ export interface BrowserSession {
|
|
|
8
9
|
createdAt: number;
|
|
9
10
|
lastSeenAt: number;
|
|
10
11
|
absoluteExpiresAt: number;
|
|
12
|
+
/** A workspace access credential, which cannot create further device links. */
|
|
13
|
+
rotating?: boolean;
|
|
11
14
|
}
|
|
12
15
|
export interface AuthResult {
|
|
13
16
|
session: BrowserSession;
|
|
@@ -19,6 +22,8 @@ interface Ticket {
|
|
|
19
22
|
purpose: 'events' | 'conversation';
|
|
20
23
|
roleId?: string;
|
|
21
24
|
expiresAt: number;
|
|
25
|
+
/** When the credential that minted this ticket stops authorizing; a socket opened with it ends then. */
|
|
26
|
+
authorizedUntil: number;
|
|
22
27
|
}
|
|
23
28
|
export declare class WebAuth {
|
|
24
29
|
private _origin;
|
|
@@ -26,6 +31,8 @@ export declare class WebAuth {
|
|
|
26
31
|
private readonly now;
|
|
27
32
|
private readonly devices;
|
|
28
33
|
private readonly access;
|
|
34
|
+
private readonly workspaceDevices?;
|
|
35
|
+
readonly appOrigin: string;
|
|
29
36
|
private _bootstrapSecret;
|
|
30
37
|
private bootstrapExpiresAt;
|
|
31
38
|
private bootstrapUsed;
|
|
@@ -33,8 +40,10 @@ export declare class WebAuth {
|
|
|
33
40
|
private readonly sessionDevices;
|
|
34
41
|
private readonly tickets;
|
|
35
42
|
private readonly rates;
|
|
43
|
+
private readonly transports;
|
|
44
|
+
private readonly renewals;
|
|
36
45
|
private readonly sockets;
|
|
37
|
-
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
|
|
46
|
+
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
|
|
38
47
|
get bootstrapSecret(): string;
|
|
39
48
|
get origin(): string;
|
|
40
49
|
get host(): string;
|
|
@@ -48,6 +57,8 @@ export declare class WebAuth {
|
|
|
48
57
|
}): void;
|
|
49
58
|
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
50
59
|
mintBootstrap(): string;
|
|
60
|
+
/** The configured account origin, when this request comes from it and workspace devices are enabled. */
|
|
61
|
+
accountOrigin(request: Pick<FastifyRequest, 'headers'>): string | undefined;
|
|
51
62
|
validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
|
|
52
63
|
exchange(request: FastifyRequest): AuthResult;
|
|
53
64
|
login(request: FastifyRequest, password: string): AuthResult;
|
|
@@ -60,9 +71,59 @@ export declare class WebAuth {
|
|
|
60
71
|
expiresAt: string;
|
|
61
72
|
};
|
|
62
73
|
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
63
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
|
|
76
|
+
* device shares one session id across credentials, so the socket ends at that credential's own
|
|
77
|
+
* deadline; revocation still ends everything the device has open.
|
|
78
|
+
*/
|
|
79
|
+
bindSocket(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, socket: WebSocket): void;
|
|
80
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
81
|
+
bindTransport(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, close: () => void, renewal?: string): () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Keep one open transport past the credential that opened it. Only its own reader knows the
|
|
84
|
+
* name it was given, and it must authenticate with a newer credential of the same device while
|
|
85
|
+
* the transport is still open: a copied credential cannot renew after it has expired.
|
|
86
|
+
*/
|
|
87
|
+
renewTransport(request: FastifyRequest, renewal: string): {
|
|
88
|
+
authorizedUntil: number;
|
|
89
|
+
};
|
|
64
90
|
clearSessions(): void;
|
|
65
91
|
revokeAllTrustedDevices(): number;
|
|
92
|
+
workspaceIdentity(): {
|
|
93
|
+
workspaceId: string;
|
|
94
|
+
};
|
|
95
|
+
workspaceEnrollment(issuer?: string | null): {
|
|
96
|
+
enrollment: string;
|
|
97
|
+
workspaceId: string;
|
|
98
|
+
expiresAt: number;
|
|
99
|
+
};
|
|
100
|
+
enrollWorkspace(request: FastifyRequest, input: {
|
|
101
|
+
enrollment: string;
|
|
102
|
+
workspaceId: string;
|
|
103
|
+
label: string;
|
|
104
|
+
credential?: unknown;
|
|
105
|
+
}): import("./workspace-devices.js").WorkspaceCredentials | {
|
|
106
|
+
token: string;
|
|
107
|
+
device: import("./workspace-devices.js").WorkspaceDevice;
|
|
108
|
+
workspaceId: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Refresh credentials are exchanged between the account server and this host only. A browser
|
|
112
|
+
* always states where a request comes from, so a request that does is refused: no page can be
|
|
113
|
+
* handed refresh material, whichever origin it runs on.
|
|
114
|
+
*/
|
|
115
|
+
private machineOnly;
|
|
116
|
+
refreshWorkspace(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials;
|
|
117
|
+
exchangeWorkspace(request: FastifyRequest): import("./workspace-devices.js").WorkspaceCredentials;
|
|
118
|
+
revokeWorkspaceRefresh(request: FastifyRequest, refreshHash: string): boolean;
|
|
119
|
+
/** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
|
|
120
|
+
linkWorkspaceDevice(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials & {
|
|
121
|
+
enrollment: string;
|
|
122
|
+
enrollmentExpiresAt: number;
|
|
123
|
+
};
|
|
124
|
+
listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
|
|
125
|
+
revokeWorkspaceDevice(id: string): void;
|
|
126
|
+
private requireWorkspaceDevices;
|
|
66
127
|
shutdown(): void;
|
|
67
128
|
private createSession;
|
|
68
129
|
private removeSession;
|
package/dist/web/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WorkspaceDeviceAuthError, WorkspaceRefreshReuseError } from './workspace-devices.js';
|
|
1
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import { FleetError } from '../application/errors.js';
|
|
3
4
|
import { TrustedDeviceStore } from './device-store.js';
|
|
@@ -14,6 +15,8 @@ export class WebAuth {
|
|
|
14
15
|
now;
|
|
15
16
|
devices;
|
|
16
17
|
access;
|
|
18
|
+
workspaceDevices;
|
|
19
|
+
appOrigin;
|
|
17
20
|
_bootstrapSecret = token(32);
|
|
18
21
|
bootstrapExpiresAt = Date.now() + 5 * 60_000;
|
|
19
22
|
bootstrapUsed = false;
|
|
@@ -21,13 +24,17 @@ export class WebAuth {
|
|
|
21
24
|
sessionDevices = new Map();
|
|
22
25
|
tickets = new Map();
|
|
23
26
|
rates = new Map();
|
|
27
|
+
transports = new Map();
|
|
28
|
+
renewals = new Map();
|
|
24
29
|
sockets = new Map();
|
|
25
|
-
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
|
|
30
|
+
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
|
|
26
31
|
this._origin = _origin;
|
|
27
32
|
this._host = _host;
|
|
28
33
|
this.now = now;
|
|
29
34
|
this.devices = devices;
|
|
30
35
|
this.access = access;
|
|
36
|
+
this.workspaceDevices = workspaceDevices;
|
|
37
|
+
this.appOrigin = appOrigin;
|
|
31
38
|
}
|
|
32
39
|
get bootstrapSecret() { return this._bootstrapSecret; }
|
|
33
40
|
get origin() { return this._origin; }
|
|
@@ -49,16 +56,24 @@ export class WebAuth {
|
|
|
49
56
|
this.bootstrapUsed = false;
|
|
50
57
|
return this._bootstrapSecret;
|
|
51
58
|
}
|
|
59
|
+
/** The configured account origin, when this request comes from it and workspace devices are enabled. */
|
|
60
|
+
accountOrigin(request) {
|
|
61
|
+
return this.workspaceDevices && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
|
|
62
|
+
}
|
|
52
63
|
validateBoundary(request, requireOrigin) {
|
|
53
64
|
const host = request.headers.host;
|
|
54
65
|
const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
|
|
55
66
|
const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
|
|
56
67
|
if (!host || !hosts.has(host))
|
|
57
68
|
throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
|
|
58
|
-
if (
|
|
69
|
+
if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
|
|
70
|
+
throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
|
|
71
|
+
const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
|
|
72
|
+
&& (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
|
|
73
|
+
if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
|
|
59
74
|
throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
|
|
60
75
|
const fetchSite = request.headers['sec-fetch-site'];
|
|
61
|
-
if (fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
|
|
76
|
+
if (!workspaceRequest && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
|
|
62
77
|
throw new FleetError('forbidden', 'cross-site request rejected');
|
|
63
78
|
}
|
|
64
79
|
exchange(request) {
|
|
@@ -100,7 +115,24 @@ export class WebAuth {
|
|
|
100
115
|
return { session: this.createSession(device.id), device };
|
|
101
116
|
}
|
|
102
117
|
authenticate(request, mutation = false) {
|
|
118
|
+
const authorization = String(request.headers.authorization ?? '');
|
|
119
|
+
if (authorization.startsWith('Bearer ')) {
|
|
120
|
+
this.validateBoundary(request, mutation);
|
|
121
|
+
if (!this.workspaceDevices)
|
|
122
|
+
throw new WorkspaceDeviceAuthError('workspace bearer credential is invalid');
|
|
123
|
+
const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
|
|
124
|
+
const id = `workspace:${device.id}`;
|
|
125
|
+
// A transport opened with this credential ends when this credential does, whatever replaces it.
|
|
126
|
+
const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.authorizedUntil, rotating: device.rotating };
|
|
127
|
+
this.sessions.set(id, session);
|
|
128
|
+
this.sessionDevices.set(id, id);
|
|
129
|
+
if (mutation)
|
|
130
|
+
this.consumeRate(`mutation:${id}`, 120, 60000);
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
103
133
|
this.validateBoundary(request, mutation);
|
|
134
|
+
if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
|
|
135
|
+
throw new FleetError('unauthorized', 'workspace bearer credential required');
|
|
104
136
|
const id = parseCookies(request.headers.cookie ?? '').ofs_session;
|
|
105
137
|
const session = id ? this.sessions.get(id) : undefined;
|
|
106
138
|
const now = this.now();
|
|
@@ -121,7 +153,9 @@ export class WebAuth {
|
|
|
121
153
|
logout(request) {
|
|
122
154
|
const session = this.authenticate(request, true);
|
|
123
155
|
const deviceId = this.sessionDevices.get(session.id);
|
|
124
|
-
if (
|
|
156
|
+
if (session.id.startsWith('workspace:'))
|
|
157
|
+
this.workspaceDevices?.revoke(session.id.slice(10));
|
|
158
|
+
else if (deviceId && deviceId !== 'unprotected')
|
|
125
159
|
this.devices.revokeId(deviceId);
|
|
126
160
|
this.removeSession(session.id);
|
|
127
161
|
}
|
|
@@ -130,7 +164,8 @@ export class WebAuth {
|
|
|
130
164
|
const value = token();
|
|
131
165
|
const ticket = {
|
|
132
166
|
value, sessionId: session.id, purpose, roleId,
|
|
133
|
-
expiresAt: this.now() + 30_000,
|
|
167
|
+
expiresAt: Math.min(this.now() + 30_000, session.absoluteExpiresAt),
|
|
168
|
+
authorizedUntil: session.absoluteExpiresAt,
|
|
134
169
|
};
|
|
135
170
|
this.tickets.set(value, ticket);
|
|
136
171
|
return { ticket: value, expiresAt: new Date(ticket.expiresAt).toISOString() };
|
|
@@ -143,20 +178,82 @@ export class WebAuth {
|
|
|
143
178
|
|| ticket.roleId !== roleId)
|
|
144
179
|
throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
|
|
145
180
|
const session = this.sessions.get(ticket.sessionId);
|
|
146
|
-
if (!session)
|
|
181
|
+
if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
|
|
147
182
|
throw new FleetError('unauthorized', 'browser session expired');
|
|
148
|
-
|
|
183
|
+
if (!session.id.startsWith('workspace:'))
|
|
184
|
+
return session;
|
|
185
|
+
// A workspace device's session entry is replaced by each newer credential; the ticket carries
|
|
186
|
+
// the deadline of the credential that minted it, and nothing newer extends it.
|
|
187
|
+
if (ticket.authorizedUntil <= this.now())
|
|
188
|
+
throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
|
|
189
|
+
return { ...session, absoluteExpiresAt: ticket.authorizedUntil };
|
|
149
190
|
}
|
|
150
|
-
|
|
191
|
+
/**
|
|
192
|
+
* `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
|
|
193
|
+
* device shares one session id across credentials, so the socket ends at that credential's own
|
|
194
|
+
* deadline; revocation still ends everything the device has open.
|
|
195
|
+
*/
|
|
196
|
+
bindSocket(session, socket) {
|
|
197
|
+
const sessionId = session.id, deadline = sessionId.startsWith('workspace:') ? session.absoluteExpiresAt : undefined;
|
|
151
198
|
let sockets = this.sockets.get(sessionId);
|
|
152
199
|
if (!sockets) {
|
|
153
200
|
sockets = new Set();
|
|
154
201
|
this.sockets.set(sessionId, sockets);
|
|
155
202
|
}
|
|
156
203
|
sockets.add(socket);
|
|
157
|
-
|
|
204
|
+
const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (deadline !== undefined ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
|
|
205
|
+
this.removeSession(sessionId);
|
|
206
|
+
else if (deadline !== undefined && deadline <= this.now()) {
|
|
207
|
+
clearInterval(expiry);
|
|
208
|
+
socket.close(4401, 'authentication expired');
|
|
209
|
+
} }, 1000);
|
|
210
|
+
expiry.unref();
|
|
211
|
+
socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
|
|
212
|
+
}
|
|
213
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
214
|
+
bindTransport(session, close, renewal) {
|
|
215
|
+
const sessionId = session.id, workspace = sessionId.startsWith('workspace:');
|
|
216
|
+
let transports = this.transports.get(sessionId);
|
|
217
|
+
if (!transports) {
|
|
218
|
+
transports = new Set();
|
|
219
|
+
this.transports.set(sessionId, transports);
|
|
220
|
+
}
|
|
221
|
+
transports.add(close);
|
|
222
|
+
const lease = { sessionId, deadline: session.absoluteExpiresAt };
|
|
223
|
+
if (workspace && renewal)
|
|
224
|
+
this.renewals.set(renewal, lease);
|
|
225
|
+
const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (workspace ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
|
|
226
|
+
this.removeSession(sessionId);
|
|
227
|
+
else if (workspace && lease.deadline <= this.now()) {
|
|
228
|
+
clearInterval(expiry);
|
|
229
|
+
transports.delete(close);
|
|
230
|
+
if (renewal)
|
|
231
|
+
this.renewals.delete(renewal);
|
|
232
|
+
close();
|
|
233
|
+
} }, 1000);
|
|
234
|
+
expiry.unref();
|
|
235
|
+
return () => { clearInterval(expiry); transports.delete(close); if (renewal)
|
|
236
|
+
this.renewals.delete(renewal); if (!transports.size)
|
|
237
|
+
this.transports.delete(sessionId); };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Keep one open transport past the credential that opened it. Only its own reader knows the
|
|
241
|
+
* name it was given, and it must authenticate with a newer credential of the same device while
|
|
242
|
+
* the transport is still open: a copied credential cannot renew after it has expired.
|
|
243
|
+
*/
|
|
244
|
+
renewTransport(request, renewal) {
|
|
245
|
+
const session = this.authenticate(request, true), lease = this.renewals.get(renewal);
|
|
246
|
+
if (!lease || lease.sessionId !== session.id || lease.deadline <= this.now())
|
|
247
|
+
throw new FleetError('resource_not_found', 'transport is not open');
|
|
248
|
+
lease.deadline = Math.max(lease.deadline, session.absoluteExpiresAt);
|
|
249
|
+
return { authorizedUntil: lease.deadline };
|
|
158
250
|
}
|
|
159
251
|
clearSessions() {
|
|
252
|
+
for (const transports of this.transports.values())
|
|
253
|
+
for (const close of transports)
|
|
254
|
+
close();
|
|
255
|
+
this.transports.clear();
|
|
256
|
+
this.renewals.clear();
|
|
160
257
|
for (const sockets of this.sockets.values())
|
|
161
258
|
for (const socket of sockets)
|
|
162
259
|
socket.close(4401, 'authentication revoked');
|
|
@@ -167,10 +264,88 @@ export class WebAuth {
|
|
|
167
264
|
}
|
|
168
265
|
revokeAllTrustedDevices() {
|
|
169
266
|
const count = this.devices.revokeAll();
|
|
267
|
+
for (const device of this.workspaceDevices?.list() ?? [])
|
|
268
|
+
this.workspaceDevices?.revoke(device.id);
|
|
170
269
|
this.clearSessions();
|
|
171
270
|
return count;
|
|
172
271
|
}
|
|
173
|
-
|
|
272
|
+
workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
|
|
273
|
+
workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
|
|
274
|
+
enrollWorkspace(request, input) {
|
|
275
|
+
if (input.credential === 2) {
|
|
276
|
+
this.machineOnly(request);
|
|
277
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
278
|
+
return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label, true);
|
|
279
|
+
}
|
|
280
|
+
this.validateBoundary(request, true);
|
|
281
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
282
|
+
return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Refresh credentials are exchanged between the account server and this host only. A browser
|
|
286
|
+
* always states where a request comes from, so a request that does is refused: no page can be
|
|
287
|
+
* handed refresh material, whichever origin it runs on.
|
|
288
|
+
*/
|
|
289
|
+
machineOnly(request) {
|
|
290
|
+
this.validateBoundary(request, false);
|
|
291
|
+
if (request.headers.origin !== undefined || Object.keys(request.headers).some(name => name.startsWith('sec-fetch-')) || request.headers.cookie !== undefined)
|
|
292
|
+
throw new FleetError('forbidden', 'this endpoint does not accept browser requests');
|
|
293
|
+
}
|
|
294
|
+
refreshWorkspace(request, refreshToken) {
|
|
295
|
+
this.machineOnly(request);
|
|
296
|
+
this.consumeRate('workspace-refresh', 240, 60000);
|
|
297
|
+
try {
|
|
298
|
+
return this.requireWorkspaceDevices().refresh(refreshToken);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof WorkspaceRefreshReuseError)
|
|
302
|
+
for (const id of error.revoked)
|
|
303
|
+
this.removeSession(`workspace:${id}`);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
exchangeWorkspace(request) {
|
|
308
|
+
this.machineOnly(request);
|
|
309
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
310
|
+
const authorization = String(request.headers.authorization ?? '');
|
|
311
|
+
const issued = this.requireWorkspaceDevices().exchange(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
|
|
312
|
+
// Whatever the fixed bearer had open ends with it.
|
|
313
|
+
this.removeSession(`workspace:${issued.device.id}`);
|
|
314
|
+
return issued;
|
|
315
|
+
}
|
|
316
|
+
revokeWorkspaceRefresh(request, refreshHash) {
|
|
317
|
+
this.machineOnly(request);
|
|
318
|
+
this.consumeRate('workspace-revoke', 120, 60000);
|
|
319
|
+
const id = this.requireWorkspaceDevices().revokeByRefreshHash(refreshHash);
|
|
320
|
+
if (id)
|
|
321
|
+
this.removeSession(`workspace:${id}`);
|
|
322
|
+
return Boolean(id);
|
|
323
|
+
}
|
|
324
|
+
/** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
|
|
325
|
+
linkWorkspaceDevice(request, refreshToken) {
|
|
326
|
+
this.machineOnly(request);
|
|
327
|
+
this.consumeRate('workspace-link', 10, 60000);
|
|
328
|
+
try {
|
|
329
|
+
return this.requireWorkspaceDevices().link(refreshToken);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error instanceof WorkspaceRefreshReuseError)
|
|
333
|
+
for (const id of error.revoked)
|
|
334
|
+
this.removeSession(`workspace:${id}`);
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
|
|
339
|
+
revokeWorkspaceDevice(id) {
|
|
340
|
+
this.requireWorkspaceDevices().revoke(id);
|
|
341
|
+
this.removeSession(`workspace:${id}`);
|
|
342
|
+
}
|
|
343
|
+
requireWorkspaceDevices() {
|
|
344
|
+
if (!this.workspaceDevices)
|
|
345
|
+
throw new FleetError('capability_unavailable', 'workspace device access is not configured');
|
|
346
|
+
return this.workspaceDevices;
|
|
347
|
+
}
|
|
348
|
+
shutdown() { this.clearSessions(); this.workspaceDevices?.close(); }
|
|
174
349
|
createSession(deviceId) {
|
|
175
350
|
const now = this.now();
|
|
176
351
|
const session = {
|
|
@@ -182,6 +357,12 @@ export class WebAuth {
|
|
|
182
357
|
return session;
|
|
183
358
|
}
|
|
184
359
|
removeSession(id) {
|
|
360
|
+
for (const close of this.transports.get(id) ?? [])
|
|
361
|
+
close();
|
|
362
|
+
this.transports.delete(id);
|
|
363
|
+
for (const [renewal, lease] of this.renewals)
|
|
364
|
+
if (lease.sessionId === id)
|
|
365
|
+
this.renewals.delete(renewal);
|
|
185
366
|
this.sessions.delete(id);
|
|
186
367
|
this.sessionDevices.delete(id);
|
|
187
368
|
for (const [ticket, value] of this.tickets)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
/** Bind the preferred loopback port; only EADDRINUSE permits an OS-selected replacement. */
|
|
3
|
+
export async function availableWebPort(preferred) {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const listen = (port) => new Promise((resolve, reject) => {
|
|
6
|
+
server.once('error', reject);
|
|
7
|
+
server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); });
|
|
8
|
+
});
|
|
9
|
+
try {
|
|
10
|
+
await listen(preferred);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== 'EADDRINUSE')
|
|
14
|
+
throw error;
|
|
15
|
+
await listen(0);
|
|
16
|
+
}
|
|
17
|
+
const address = server.address();
|
|
18
|
+
if (!address || typeof address === 'string')
|
|
19
|
+
throw Error('No loopback port selected');
|
|
20
|
+
const port = address.port;
|
|
21
|
+
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
22
|
+
return port;
|
|
23
|
+
}
|
package/dist/web/control.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WebControlCommand = '
|
|
1
|
+
export type WebControlCommand = 'revoke-all' | 'link-device';
|
|
2
2
|
export declare const webControlPath: (dir?: string) => string;
|
|
3
3
|
export interface WebControlServer {
|
|
4
4
|
path: string;
|
|
@@ -6,9 +6,9 @@ export interface WebControlServer {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function startWebControlServer(options: {
|
|
8
8
|
dir?: string;
|
|
9
|
-
|
|
9
|
+
onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
10
10
|
onRevokeAll(): void | Promise<void>;
|
|
11
11
|
now?: () => number;
|
|
12
12
|
rateLimit?: number;
|
|
13
13
|
}): Promise<WebControlServer>;
|
|
14
|
-
export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
|
|
14
|
+
export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<Record<string, unknown> | void>;
|