@huynle/hub 0.6.1 → 0.7.2
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/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/http.ts
CHANGED
|
@@ -1,26 +1,70 @@
|
|
|
1
1
|
// Hub: HTTP routes (web UI, JSON API, SSE, enrollment) and the spoke WebSocket.
|
|
2
|
-
// Phones authenticate with the `voice_device` cookie
|
|
2
|
+
// Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
|
|
3
|
+
// token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
|
|
3
4
|
// (including loopback) are never trusted.
|
|
4
5
|
import type { Server } from "bun"
|
|
5
6
|
import { diskAssets, type WebAssets } from "./assets"
|
|
6
7
|
import path from "node:path"
|
|
7
|
-
import {
|
|
8
|
+
import { promisify } from "node:util"
|
|
9
|
+
import { brotliCompress, constants as zlib, gzip } from "node:zlib"
|
|
10
|
+
import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
|
|
8
11
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
9
12
|
import { speakable } from "../speakable"
|
|
10
13
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
11
14
|
import { emailAllowed, type CentralConfig } from "./config"
|
|
12
15
|
import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
|
|
13
|
-
import { Push, type PushSender } from "./push"
|
|
16
|
+
import { isNotificationKind, Push, type PushSender } from "./push"
|
|
14
17
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
18
|
+
import { Webhooks } from "./agent/webhooks"
|
|
19
|
+
import { issueAppToken } from "./agent/app-tokens"
|
|
20
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
|
|
21
|
+
import { EventWriters } from "./agent/event-writers"
|
|
22
|
+
import { MemoryEventLog } from "./backend/memory-events"
|
|
23
|
+
import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
|
|
24
|
+
import { hubBackends, type Backends } from "./backend"
|
|
25
|
+
import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
|
|
26
|
+
import { ActingSwitch } from "./agent/acting-switch"
|
|
27
|
+
import { AgentAudit } from "./agent/audit"
|
|
28
|
+
import { Grants } from "./agent/grants"
|
|
29
|
+
import { Decisions, type Decision } from "./agent/decisions"
|
|
30
|
+
import type { Escalate } from "./agent/acting"
|
|
31
|
+
import { Escalations } from "./agent/escalation"
|
|
32
|
+
import { GrantRequests } from "./agent/grant-requests"
|
|
33
|
+
import { partyFromActor } from "./agent/envelope"
|
|
34
|
+
import { Delivery } from "./agent/delivery"
|
|
35
|
+
import { AgentError, type AgentErrorCode } from "./agent/errors"
|
|
36
|
+
import type { Launch } from "./agent/launch-store"
|
|
37
|
+
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
38
|
+
import { hubDirectory } from "./agent/directory-routes"
|
|
39
|
+
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
40
|
+
import { MCP_PATH, mcpHttp } from "./mcp-http"
|
|
41
|
+
import { SessionTokens } from "./agent/session-tokens"
|
|
42
|
+
import { SessionTokenPush } from "./session-token-push"
|
|
43
|
+
import { TokenKeys } from "./token-keys"
|
|
44
|
+
import { EventLog } from "./events-log"
|
|
15
45
|
import { BusyError, Registry } from "./registry"
|
|
46
|
+
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
16
47
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
17
48
|
import { SpeechService } from "./speech/service"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
49
|
+
import { NodeRegistry } from "./nodes"
|
|
50
|
+
import { ConnDirectory } from "./cluster/directory"
|
|
51
|
+
import { PodRpc } from "./cluster/rpc"
|
|
52
|
+
import { ClusterView } from "./cluster/view"
|
|
53
|
+
import { LeaderLoop, NoLeaderError } from "./cluster/leader"
|
|
54
|
+
import { ClusterRecommendations } from "./cluster/recommend"
|
|
55
|
+
import { LiveFanout, type LiveEvent } from "./cluster/live"
|
|
56
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
57
|
+
import { LaunchEngine } from "./agent/launch-engine"
|
|
58
|
+
import { LaunchTokens } from "./agent/launch-tokens"
|
|
59
|
+
import { LaunchFeed, launchInfo } from "./agent/launch-routes"
|
|
60
|
+
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
61
|
+
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
20
62
|
|
|
21
63
|
export interface Speech {
|
|
22
64
|
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
23
65
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
66
|
+
/** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
|
|
67
|
+
speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
24
68
|
}
|
|
25
69
|
|
|
26
70
|
export interface CentralOptions {
|
|
@@ -37,16 +81,36 @@ export interface CentralOptions {
|
|
|
37
81
|
readonly notifier?: Notifier
|
|
38
82
|
/** Overrides web-push delivery (tests; web-push itself needs https push services). */
|
|
39
83
|
readonly pushSender?: PushSender
|
|
40
|
-
/** Resolves push endpoint
|
|
84
|
+
/** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
|
|
41
85
|
readonly resolveHost?: ResolveHost
|
|
42
|
-
/** Clock for sign-in expiry and the
|
|
86
|
+
/** Clock for sign-in expiry, the store, the registry and the backends (tests). */
|
|
43
87
|
readonly now?: () => number
|
|
88
|
+
/** Shared state for new code (default: in memory, see ./backend). */
|
|
89
|
+
readonly backends?: Backends
|
|
90
|
+
/** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
|
|
91
|
+
readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
|
|
44
92
|
readonly timing?: {
|
|
45
93
|
readonly helloTimeoutMs?: number
|
|
46
94
|
readonly requestTimeoutMs?: number
|
|
47
95
|
readonly turnGraceMs?: number
|
|
96
|
+
/** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
|
|
97
|
+
readonly turnAcceptMs?: number
|
|
48
98
|
readonly heartbeatMs?: number
|
|
49
99
|
readonly staleMs?: number
|
|
100
|
+
/** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
|
|
101
|
+
readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
|
|
102
|
+
/** Cluster mode: how often each pod re-checks its sockets' machines and owners (see RECHECK_MS). */
|
|
103
|
+
readonly recheckMs?: number
|
|
104
|
+
/** Cluster mode: the cluster view's heartbeat and slice TTL (see VIEW_HEARTBEAT_MS, VIEW_TTL_MS). */
|
|
105
|
+
readonly viewHeartbeatMs?: number
|
|
106
|
+
readonly viewTtlMs?: number
|
|
107
|
+
/** Leader leases of the timed jobs (see LEADER_TTL_MS). */
|
|
108
|
+
readonly leaderTtlMs?: number
|
|
109
|
+
/** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
|
|
110
|
+
readonly loopTickMs?: number
|
|
111
|
+
/** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
|
|
112
|
+
readonly drainTimeoutMs?: number
|
|
113
|
+
readonly drainRetryAfterMs?: number
|
|
50
114
|
}
|
|
51
115
|
}
|
|
52
116
|
|
|
@@ -56,11 +120,40 @@ export interface Central {
|
|
|
56
120
|
readonly store: Store
|
|
57
121
|
readonly auth: Auth
|
|
58
122
|
readonly registry: Registry
|
|
123
|
+
/** Connected `hub node` daemons (never in the registry). */
|
|
124
|
+
readonly nodes: NodeRegistry
|
|
59
125
|
readonly spokes: SpokeHub
|
|
60
126
|
readonly push: Push
|
|
61
127
|
readonly speechSettings: SpeechService
|
|
128
|
+
/** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
|
|
129
|
+
readonly backends: Backends
|
|
130
|
+
/** Agent messages: store and delivery to sessions (the message routes use it). */
|
|
131
|
+
readonly delivery: Delivery
|
|
132
|
+
/** Decisions (ask @me): answered by the user on /api/decisions. */
|
|
133
|
+
readonly decisions: Decisions
|
|
134
|
+
/** Kill switches (per user and hub-wide) and hub.json agents.never, for the acting verbs. */
|
|
135
|
+
readonly acting: ActingSwitch
|
|
136
|
+
/** Launches (design §6): accept, list, stop; placement onto nodes and each launch's run to its end. */
|
|
137
|
+
readonly launches: LaunchEngine
|
|
138
|
+
/** Every launch change, per user (SSE 'launch' events and the agent watch subscribe; webhooks hang off it). */
|
|
139
|
+
readonly launchFeed: LaunchFeed
|
|
140
|
+
/** Launch-bound (hlt1) tokens: what a sandboxed harness connects back to /spoke/ws with (phase 5). */
|
|
141
|
+
readonly launchTokens: LaunchTokens
|
|
142
|
+
/** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
|
|
143
|
+
readonly webhooks: Webhooks
|
|
144
|
+
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
145
|
+
readonly agentEvents: AgentEvents
|
|
146
|
+
/** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
|
|
147
|
+
readonly agentEventWriters: EventWriters
|
|
62
148
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
63
149
|
readonly routes: readonly RouteInfo[]
|
|
150
|
+
/**
|
|
151
|
+
* Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
|
|
152
|
+
* nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
|
|
153
|
+
* are released, cluster view slices withdrawn, SSE streams end with a `retry:` hint, and it resolves when requests
|
|
154
|
+
* in flight are done (at most timeoutMs; the rest fail as offline). Call stop() after it. Repeated calls share one.
|
|
155
|
+
*/
|
|
156
|
+
drain(options?: DrainOptions): Promise<void>
|
|
64
157
|
stop(): Promise<void>
|
|
65
158
|
}
|
|
66
159
|
|
|
@@ -69,12 +162,25 @@ type Message = { type: string; [key: string]: unknown }
|
|
|
69
162
|
interface Listener {
|
|
70
163
|
readonly userID: string
|
|
71
164
|
readonly deviceID: string
|
|
72
|
-
|
|
165
|
+
/** Leaves out streamed text and reasoning (except of `session`'s tree). */
|
|
166
|
+
readonly lite: boolean
|
|
167
|
+
/** The session the page shows: its tree's text and reasoning stream even when lite. */
|
|
168
|
+
readonly session?: string
|
|
169
|
+
/** Sends a message; `id` is its event id (EventSource's lastEventId). */
|
|
170
|
+
send(message: Message, id?: string): void
|
|
73
171
|
close(): void
|
|
172
|
+
/** Draining: tells the browser to reconnect after retryMs (`retry:`), then ends the stream. */
|
|
173
|
+
end(retryMs: number): void
|
|
74
174
|
}
|
|
75
175
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
176
|
+
/** Messages a lite stream leaves out (unless they are of the listener's session tree). */
|
|
177
|
+
const STREAMED = new Set(["delta", "reasoning"])
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
|
|
181
|
+
* "agent": an /api/agent route (src/central/agent), checked per credential kind there.
|
|
182
|
+
*/
|
|
183
|
+
export type Access = "public" | "user" | "admin" | "session" | "machine" | "agent"
|
|
78
184
|
|
|
79
185
|
export interface RouteInfo {
|
|
80
186
|
readonly method: string
|
|
@@ -107,7 +213,23 @@ const NO_STORE = { "cache-control": "no-store" }
|
|
|
107
213
|
export const COOKIE = "voice_device"
|
|
108
214
|
const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
|
|
109
215
|
const SSE_HEARTBEAT_MS = 20_000
|
|
216
|
+
/** Draining: the `retry:` an ending SSE stream sends (the browser reconnects, through the load balancer, after it). */
|
|
217
|
+
const SSE_RETRY_MS = 2_000
|
|
218
|
+
const SSE_RETRY = `retry: ${SSE_RETRY_MS}\n\n`
|
|
219
|
+
/** /readyz in cluster mode: how long the database and Valkey get to answer. */
|
|
220
|
+
const READY_CHECK_MS = 2_000
|
|
221
|
+
/** Cluster mode: how much longer than the turn timeout a turn's cluster-wide lease lasts (it is released at its end). */
|
|
222
|
+
const TURN_LEASE_SLACK_MS = 120_000
|
|
223
|
+
/** Cluster mode: how long a stop sent to the launches leader may take (it waits for the node's collect and cleanup). */
|
|
224
|
+
const LAUNCH_STOP_TIMEOUT_MS = 180_000
|
|
225
|
+
/**
|
|
226
|
+
* `POST …/turn` with `Prefer: respond-async`: a turn that has not settled after this long is answered 202 and
|
|
227
|
+
* finishes in the background (result as an SSE `turn` event). Long enough for the quick refusals (busy, unknown
|
|
228
|
+
* session, spoke error), short enough that no proxy cuts the request (Traefik's default readTimeout is 60 s).
|
|
229
|
+
*/
|
|
230
|
+
export const TURN_ACCEPT_MS = 1500
|
|
110
231
|
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
232
|
+
const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
|
|
111
233
|
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
112
234
|
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
113
235
|
const PERMISSION_GONE = "This request was already answered or expired"
|
|
@@ -149,6 +271,29 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
149
271
|
".svg": "image/svg+xml",
|
|
150
272
|
".png": "image/png",
|
|
151
273
|
".ico": "image/x-icon",
|
|
274
|
+
// Voice mode's on-device speech detection (Silero VAD through onnxruntime-web)
|
|
275
|
+
".wasm": "application/wasm",
|
|
276
|
+
".onnx": "application/octet-stream",
|
|
277
|
+
}
|
|
278
|
+
/** Vite's content-hashed build output: a changed file gets a new name, so it can be cached for good. */
|
|
279
|
+
const HASHED_ASSET = /^assets\//
|
|
280
|
+
const IMMUTABLE = "public, max-age=31536000, immutable"
|
|
281
|
+
/** Hashed assets sent compressed when the browser accepts it (onnxruntime's wasm: 12 MB → 2.3 MB with Brotli). */
|
|
282
|
+
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".wasm", ".json", ".svg"])
|
|
283
|
+
const MIN_COMPRESS_BYTES = 1024
|
|
284
|
+
const brotli = promisify(brotliCompress)
|
|
285
|
+
const gzipAsync = promisify(gzip)
|
|
286
|
+
|
|
287
|
+
/** "br" or "gzip" when the Accept-Encoding header allows it (q=0 refuses), else undefined. */
|
|
288
|
+
function pickEncoding(header: string | null): "br" | "gzip" | undefined {
|
|
289
|
+
const accepted = new Map<string, number>()
|
|
290
|
+
for (const part of (header ?? "").toLowerCase().split(",")) {
|
|
291
|
+
const [name, ...params] = part.trim().split(";")
|
|
292
|
+
const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)?.[1]).find(Boolean)
|
|
293
|
+
if (name) accepted.set(name.trim(), q === undefined ? 1 : Number(q))
|
|
294
|
+
}
|
|
295
|
+
for (const encoding of ["br", "gzip"] as const) if ((accepted.get(encoding) ?? 0) > 0) return encoding
|
|
296
|
+
return undefined
|
|
152
297
|
}
|
|
153
298
|
|
|
154
299
|
class HttpError extends Error {
|
|
@@ -162,38 +307,111 @@ class HttpError extends Error {
|
|
|
162
307
|
}
|
|
163
308
|
}
|
|
164
309
|
|
|
310
|
+
/** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
|
|
311
|
+
const unsupportedError = (lacking: Pick<Unsupported, "feature" | "harness">) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
|
|
312
|
+
|
|
165
313
|
const error = (status: number, message: string, code?: string) =>
|
|
166
314
|
Response.json({ error: message, ...(code ? { code } : {}) }, { status })
|
|
167
315
|
|
|
168
316
|
export function startCentral(options: CentralOptions): Central {
|
|
169
317
|
const { config } = options
|
|
318
|
+
const heartbeatMs = options.timing?.heartbeatMs ?? SSE_HEARTBEAT_MS
|
|
170
319
|
const web: WebAssets = options.web ?? diskAssets(options.webDir ?? path.join(import.meta.dir, "..", "..", "web", "dist"))
|
|
171
320
|
const now = options.now ?? Date.now
|
|
172
|
-
|
|
321
|
+
/** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
|
|
322
|
+
const ownBackends = !options.backends
|
|
323
|
+
const backends = options.backends ?? hubBackends(config, { now })
|
|
324
|
+
// Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
|
|
325
|
+
// migration runs on start). Otherwise state.json in the state dir.
|
|
326
|
+
const store = config.cluster
|
|
327
|
+
? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
|
|
328
|
+
: Store.open(config.stateDir, now, { mode: config.auth.mode })
|
|
173
329
|
// A machine's tenant is its owner (read live: ownership moves on migration).
|
|
174
|
-
const registry = new Registry(
|
|
175
|
-
const
|
|
330
|
+
const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
331
|
+
const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
|
|
332
|
+
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
|
|
176
333
|
const notifier = options.notifier ?? push
|
|
177
334
|
/** SSE subscribers, one user each. */
|
|
178
335
|
const listeners = new Set<Listener>()
|
|
336
|
+
/** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
|
|
337
|
+
const eventLog = new EventLog()
|
|
338
|
+
/** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
|
|
339
|
+
const wants = (listener: Listener, message: Message): boolean => {
|
|
340
|
+
if (!listener.lite || !STREAMED.has(message.type)) return true
|
|
341
|
+
if (!listener.session) return false
|
|
342
|
+
const root = registry.status(listener.session)?.[0]?.id ?? listener.session
|
|
343
|
+
return message.sessionID === listener.session || message.rootID === root || message.sessionID === root
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
|
|
347
|
+
* EventLog first (except streamed text), so consoles on any pod get them and resume by Last-Event-ID on any pod.
|
|
348
|
+
*/
|
|
349
|
+
const live = config.cluster ? new LiveFanout({ bus: backends.bus, events: backends.events, podID: config.cluster.podID }) : undefined
|
|
350
|
+
/** Sends a message to this process's streams of a user (except `exceptDevice`'s); a catalog is built per user. */
|
|
351
|
+
const deliver = (userID: string, message: Message, id?: string, exceptDevice?: string) => {
|
|
352
|
+
for (const listener of [...listeners]) {
|
|
353
|
+
if (listener.userID !== userID || listener.deviceID === exceptDevice) continue
|
|
354
|
+
if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(userID) }, id)
|
|
355
|
+
else if (wants(listener, message)) listener.send(message, id)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Logs a message for one user and sends it to their streams (except `exceptDevice`'s). */
|
|
359
|
+
const toUser = (userID: string, message: Message, exceptDevice?: string) => {
|
|
360
|
+
if (live) {
|
|
361
|
+
// A catalog is this pod's own view (its registry covers the cluster): never logged, never sent to other pods.
|
|
362
|
+
if (message.type === "catalog") deliver(userID, message)
|
|
363
|
+
// Failures are logged by the fan-out.
|
|
364
|
+
else live.send(userID, message, exceptDevice).catch(() => {})
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
// A catalog is logged as a marker: a replay sends one fresh catalog for any number of them.
|
|
368
|
+
const id = eventLog.append(userID, message.type === "catalog" ? { type: "catalog" } : message, exceptDevice)
|
|
369
|
+
deliver(userID, message, id, exceptDevice)
|
|
370
|
+
}
|
|
371
|
+
/** Closes the streams of users who were disabled or removed. */
|
|
372
|
+
const closeDisabled = () => {
|
|
373
|
+
for (const listener of [...listeners]) {
|
|
374
|
+
const user = store.user(listener.userID)
|
|
375
|
+
if (!user || user.disabled) listener.close()
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Users that may receive an `audience` message (cluster mode: whether or not they stream on this pod). */
|
|
379
|
+
const audienceUsers = (audience: { tenant?: string; admins?: true }): string[] => {
|
|
380
|
+
if (audience.admins) return store.users().filter((u) => u.role === "admin" && !u.disabled).map((u) => u.id)
|
|
381
|
+
if (audience.tenant === undefined) return []
|
|
382
|
+
const user = store.user(audience.tenant)
|
|
383
|
+
return user && !user.disabled ? [user.id] : []
|
|
384
|
+
}
|
|
179
385
|
/**
|
|
180
386
|
* Sends to SSE subscribers: catalog to everyone (each gets their own machines), `admins` messages to
|
|
181
387
|
* admins only, anything else only to `tenant` (the session's owner). Streams of users who were
|
|
182
388
|
* disabled are closed.
|
|
183
389
|
*/
|
|
184
390
|
const broadcast = (message: Message, audience: { tenant?: string; admins?: true } = {}) => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
391
|
+
closeDisabled()
|
|
392
|
+
if (live) {
|
|
393
|
+
// Stored machines changed: every pod sends its streams a fresh catalog (catalogHere).
|
|
394
|
+
if (message.type === "catalog") live.publishCatalog().catch((cause) => console.error(`[hub] catalog announcement failed: ${(cause as Error).message}`))
|
|
395
|
+
else for (const userID of audienceUsers(audience)) toUser(userID, message)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
const users = new Set([...eventLog.users(), ...[...listeners].map((l) => l.userID)])
|
|
399
|
+
for (const userID of users) {
|
|
400
|
+
const user = store.user(userID)
|
|
401
|
+
if (!user || user.disabled) continue
|
|
402
|
+
if (message.type === "catalog") toUser(userID, message)
|
|
192
403
|
else if (audience.admins) {
|
|
193
|
-
if (user.role === "admin")
|
|
194
|
-
} else if (audience.tenant !== undefined && audience.tenant ===
|
|
404
|
+
if (user.role === "admin") toUser(userID, message)
|
|
405
|
+
} else if (audience.tenant !== undefined && audience.tenant === userID) toUser(userID, message)
|
|
195
406
|
}
|
|
196
407
|
}
|
|
408
|
+
/** Cluster mode: a fresh catalog to this pod's streams (the registry's cluster-wide view changed). */
|
|
409
|
+
const catalogHere = () => {
|
|
410
|
+
closeDisabled()
|
|
411
|
+
for (const userID of new Set([...listeners].map((l) => l.userID))) toUser(userID, { type: "catalog" })
|
|
412
|
+
}
|
|
413
|
+
live?.onCatalog(catalogHere)
|
|
414
|
+
live?.start().catch((cause) => console.error(`[hub] live fan-out did not start: ${(cause as Error).message}`))
|
|
197
415
|
const speechSettings = new SpeechService({
|
|
198
416
|
store,
|
|
199
417
|
defaults: {
|
|
@@ -210,6 +428,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
210
428
|
let publicURL = config.publicURL
|
|
211
429
|
let auth!: Auth
|
|
212
430
|
let spokes!: SpokeHub
|
|
431
|
+
/** Set by drain() (design §10): /readyz fails and new SSE streams are sent away. */
|
|
432
|
+
let draining: Promise<void> | undefined
|
|
433
|
+
/** Aborted by drain(): the agent watch streams end with a `retry:` hint. */
|
|
434
|
+
const drainSignal = new AbortController()
|
|
213
435
|
/** At most one machine per hub process is approved without confirmation (see localAutoApprove). */
|
|
214
436
|
let autoApproved = false
|
|
215
437
|
|
|
@@ -233,22 +455,62 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
233
455
|
const origins = loopbackOrigins(publicURL)
|
|
234
456
|
const secure = publicURL.startsWith("https:")
|
|
235
457
|
|
|
236
|
-
auth = new Auth({ store, publicURL, now, mode: config.auth.mode })
|
|
458
|
+
auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
|
|
237
459
|
// options.fetch is for speech providers only; sign-in always talks to the real issuer.
|
|
238
|
-
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now }) : undefined
|
|
460
|
+
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
|
|
239
461
|
/** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
|
|
240
462
|
const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
|
|
241
463
|
const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
|
|
464
|
+
const tokenKeys = TokenKeys.open(config.stateDir)
|
|
465
|
+
// Launch-bound tokens (phase 5): checked against the launch engine's store (created below; read only at use).
|
|
466
|
+
const launchTokens: LaunchTokens = new LaunchTokens({ keys: tokenKeys, store, launches: { launch: (userID, launchID) => launches.store.get(userID, launchID) }, publicURL, now })
|
|
242
467
|
spokes = new SpokeHub({
|
|
243
468
|
store,
|
|
244
469
|
auth,
|
|
245
470
|
registry,
|
|
471
|
+
nodes,
|
|
246
472
|
publicURL,
|
|
247
473
|
notifier,
|
|
248
474
|
broadcast: (message, tenant) => broadcast(message, { tenant }),
|
|
249
475
|
turnTimeoutMs: config.turnTimeoutMs,
|
|
476
|
+
launchTokens,
|
|
477
|
+
// Cluster mode: connections are found through the shared directory and reached over pod RPC (design §9).
|
|
478
|
+
...(config.cluster
|
|
479
|
+
? { cluster: { directory: new ConnDirectory({ kv: backends.kv, podID: config.cluster.podID }), rpc: new PodRpc({ bus: backends.bus, podID: config.cluster.podID }) } }
|
|
480
|
+
: {}),
|
|
250
481
|
...options.timing,
|
|
251
482
|
})
|
|
483
|
+
spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
|
|
484
|
+
// Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
|
|
485
|
+
// registries' reads cover the whole cluster (design §9).
|
|
486
|
+
const view = config.cluster
|
|
487
|
+
? new ClusterView({
|
|
488
|
+
catalog: backends.catalog,
|
|
489
|
+
bus: backends.bus,
|
|
490
|
+
podID: config.cluster.podID,
|
|
491
|
+
registry,
|
|
492
|
+
nodes,
|
|
493
|
+
users: async () => {
|
|
494
|
+
await store.ready()
|
|
495
|
+
return store.users().map((u) => u.id)
|
|
496
|
+
},
|
|
497
|
+
...(options.timing?.viewHeartbeatMs !== undefined ? { heartbeatMs: options.timing.viewHeartbeatMs } : {}),
|
|
498
|
+
...(options.timing?.viewTtlMs !== undefined ? { ttlMs: options.timing.viewTtlMs } : {}),
|
|
499
|
+
})
|
|
500
|
+
: undefined
|
|
501
|
+
view?.start().catch((cause) => console.error(`[hub] cluster view did not start: ${(cause as Error).message}`))
|
|
502
|
+
// Session tokens (design §1.3): minted for the sessions spokes report, pushed on their sessions frames.
|
|
503
|
+
const sessionTokens = new SessionTokens({ keys: tokenKeys, store, sessions: registry, publicURL, now })
|
|
504
|
+
const tokenPush = new SessionTokenPush({
|
|
505
|
+
registry,
|
|
506
|
+
tokens: sessionTokens,
|
|
507
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
508
|
+
hubURL: publicURL,
|
|
509
|
+
now,
|
|
510
|
+
log: (message) => console.error(message),
|
|
511
|
+
})
|
|
512
|
+
registry.onSessions((connID) => void tokenPush.sessions(connID))
|
|
513
|
+
tokenPush.start()
|
|
252
514
|
|
|
253
515
|
/** The user's machines with their live processes and sessions. */
|
|
254
516
|
const machines = (userID: string) => {
|
|
@@ -267,12 +529,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
267
529
|
}))
|
|
268
530
|
}
|
|
269
531
|
|
|
270
|
-
|
|
532
|
+
// Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
|
|
533
|
+
// goes to the tenant's streams on every pod.
|
|
534
|
+
registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
|
|
271
535
|
registry.subscribeAlerts((alert) => {
|
|
272
536
|
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
273
537
|
if (!alert.owner) return
|
|
274
|
-
|
|
275
|
-
|
|
538
|
+
// Activity updates are collected and throttled per device by the push layer.
|
|
539
|
+
if (alert.kind === "activity") return push.activity(alert)
|
|
540
|
+
const extra = {
|
|
541
|
+
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
542
|
+
...(alert.subject ? { subject: alert.subject } : {}),
|
|
543
|
+
...(alert.key ? { key: alert.key } : {}),
|
|
544
|
+
}
|
|
545
|
+
notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
|
|
276
546
|
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
277
547
|
})
|
|
278
548
|
})
|
|
@@ -284,6 +554,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
284
554
|
{
|
|
285
555
|
prefs: store.pushPrefs(deviceID),
|
|
286
556
|
quietHours: store.quietHours(deviceID) ?? null,
|
|
557
|
+
activityMinutes: store.activityMinutes(deviceID),
|
|
287
558
|
muted: store.mutedSessions(userID),
|
|
288
559
|
subscriptions: mine.length,
|
|
289
560
|
...(endpoint ? { subscribed: mine.some((s) => s.endpoint === endpoint) } : {}),
|
|
@@ -296,12 +567,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
296
567
|
`${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
|
|
297
568
|
|
|
298
569
|
/** The device of the voice_device cookie and its user; undefined when unknown or the user is disabled. */
|
|
299
|
-
|
|
570
|
+
/** The device of a token (cookie value or device token), its owner re-read too (a shared store's security re-check). */
|
|
571
|
+
const deviceOfToken = async (token: string): Promise<DeviceRecord | undefined> => {
|
|
572
|
+
const device = await store.findDevice(token)
|
|
573
|
+
if (device) await store.recheck({ users: [device.ownerID] })
|
|
574
|
+
return device
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const deviceFor = async (request: Request): Promise<{ device: DeviceRecord; user: UserRecord } | undefined> => {
|
|
300
578
|
const header = request.headers.get("cookie") ?? ""
|
|
301
579
|
for (const part of header.split(";")) {
|
|
302
580
|
const [name, ...rest] = part.trim().split("=")
|
|
303
581
|
if (name !== COOKIE) continue
|
|
304
|
-
const device =
|
|
582
|
+
const device = await deviceOfToken(rest.join("="))
|
|
305
583
|
if (!device) continue
|
|
306
584
|
const user = store.user(device.ownerID)
|
|
307
585
|
return user && !user.disabled ? { device, user } : undefined
|
|
@@ -329,34 +607,34 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
329
607
|
result = await oidc!.callback(url.searchParams, cookieValue(request.headers.get("cookie"), OIDC_COOKIE))
|
|
330
608
|
} catch (cause) {
|
|
331
609
|
if (!(cause instanceof OidcError)) throw cause
|
|
332
|
-
if (cause.audited) store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
|
|
610
|
+
if (cause.audited) await store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
|
|
333
611
|
else console.warn(`[hub] sign-in refused: ${cause.reason}`)
|
|
334
612
|
return deny(cause.status, "Sign-in failed", cause.message)
|
|
335
613
|
}
|
|
336
614
|
const { identity, next } = result
|
|
337
615
|
if (!emailAllowed(settings, identity.email)) {
|
|
338
|
-
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
|
|
616
|
+
await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
|
|
339
617
|
return deny(403, "Not allowed", `${identity.email} is not allowed to use this hub. Ask its admin to add you.`)
|
|
340
618
|
}
|
|
341
619
|
const role = settings.admins.includes(identity.email) ? "admin" : "user"
|
|
342
620
|
const existing = store.userByEmail(identity.email)
|
|
343
621
|
if (existing?.disabled) {
|
|
344
|
-
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
|
|
622
|
+
await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
|
|
345
623
|
return deny(403, "Account disabled", "Your account on this hub is disabled. Ask its admin.")
|
|
346
624
|
}
|
|
347
|
-
const user = store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
|
|
348
|
-
if (user.role === "admin" && store.migrateLocal(user.id)) broadcast({ type: "catalog" })
|
|
625
|
+
const user = await store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
|
|
626
|
+
if (user.role === "admin" && await store.migrateLocal(user.id)) broadcast({ type: "catalog" })
|
|
349
627
|
const headers = new Headers({ "cache-control": "no-store" })
|
|
350
628
|
headers.append("set-cookie", cleared["set-cookie"])
|
|
351
|
-
const current = deviceFor(request)
|
|
629
|
+
const current = await deviceFor(request)
|
|
352
630
|
let deviceID: string
|
|
353
631
|
if (current && current.user.id === user.id) deviceID = current.device.id
|
|
354
632
|
else {
|
|
355
|
-
const { device, token } = store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
|
|
633
|
+
const { device, token } = await store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
|
|
356
634
|
deviceID = device.id
|
|
357
635
|
headers.append("set-cookie", cookie(token))
|
|
358
636
|
}
|
|
359
|
-
store.audit({ actor: user.id, action: "signin", target: deviceID })
|
|
637
|
+
await store.audit({ actor: user.id, action: "signin", target: deviceID })
|
|
360
638
|
// Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
|
|
361
639
|
const setup = !next.includes("#") && speechSettings.view(speechScope(user.id)).ready.reason ? "#speech" : ""
|
|
362
640
|
headers.set("location", `${next}${setup}`)
|
|
@@ -376,17 +654,303 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
376
654
|
}
|
|
377
655
|
}
|
|
378
656
|
|
|
657
|
+
/** A push to the user about one session (titled by it; "" opens the console), whatever device shows it. */
|
|
658
|
+
const pushUser = (kind: "decision" | "message", userID: string, sessionID: string, key: string, decisionID?: string) => {
|
|
659
|
+
const title = (sessionID && registry.status(sessionID)?.find((s) => s.id === sessionID)?.title) || "Hub"
|
|
660
|
+
notifier.notify(kind, sessionID, title, undefined, userID, { key, ...(decisionID ? { decisionID } : {}) }).catch((cause) => {
|
|
661
|
+
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const directory = hubDirectory({ registry, store, now })
|
|
666
|
+
/** This process as owner of leases (leader loops, flushes, turns): `<pod>:<pid>-<random>`. */
|
|
667
|
+
const leaseOwner = `${config.cluster?.podID ?? "hub"}:${process.pid}-${randomBytes(4).toString("hex")}`
|
|
668
|
+
/** Every stored user (the leader loops look at all of them). */
|
|
669
|
+
const allUsers = async () => {
|
|
670
|
+
await store.ready()
|
|
671
|
+
return store.users().map((u) => u.id)
|
|
672
|
+
}
|
|
673
|
+
const loopTickMs = options.timing?.loopTickMs
|
|
674
|
+
// Timed jobs run on one pod at a time, under a fenced leader lease (design §9; a single hub always leads).
|
|
675
|
+
const leaders: LeaderLoop[] = []
|
|
676
|
+
const leader = (name: string, handlers: Pick<ConstructorParameters<typeof LeaderLoop>[0], "onElected" | "onDeposed" | "onKick" | "onRequest">) => {
|
|
677
|
+
const loop = new LeaderLoop({
|
|
678
|
+
locks: backends.locks,
|
|
679
|
+
bus: backends.bus,
|
|
680
|
+
name,
|
|
681
|
+
owner: leaseOwner,
|
|
682
|
+
...(options.timing?.leaderTtlMs !== undefined ? { ttlMs: options.timing.leaderTtlMs } : {}),
|
|
683
|
+
...handlers,
|
|
684
|
+
})
|
|
685
|
+
leaders.push(loop)
|
|
686
|
+
return loop
|
|
687
|
+
}
|
|
688
|
+
// Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
|
|
689
|
+
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
690
|
+
const delivery = new Delivery({
|
|
691
|
+
state: backends.state,
|
|
692
|
+
counters: backends.counters,
|
|
693
|
+
registry,
|
|
694
|
+
directory,
|
|
695
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
696
|
+
now,
|
|
697
|
+
describe: (userID, sessionID) => {
|
|
698
|
+
try {
|
|
699
|
+
const entry = directory.resolve(userID, sessionID).sessions[0]
|
|
700
|
+
const machine = entry?.machineID ? store.machines().find((m) => m.id === entry.machineID)?.name : undefined
|
|
701
|
+
return { ...(entry?.name ? { name: entry.name } : {}), ...(machine ? { machine } : {}) }
|
|
702
|
+
} catch {
|
|
703
|
+
return {}
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
// Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
|
|
707
|
+
alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
|
|
708
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
709
|
+
// Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
|
|
710
|
+
...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
|
|
711
|
+
})
|
|
712
|
+
/** Message events (watch, waiters), shared by the agent routes and the console's Decision answers. */
|
|
713
|
+
const feed = new MessageFeed()
|
|
714
|
+
// Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
|
|
715
|
+
if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
|
|
716
|
+
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
717
|
+
const decisions = new Decisions({
|
|
718
|
+
state: backends.state,
|
|
719
|
+
delivery,
|
|
720
|
+
now,
|
|
721
|
+
users: () => store.users().map((u) => u.id),
|
|
722
|
+
notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
|
|
723
|
+
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
724
|
+
changed: (userID, decision) => broadcast({ type: "decision", decision }, { tenant: userID }),
|
|
725
|
+
// Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user
|
|
726
|
+
// (both are set up below).
|
|
727
|
+
act: (userID, decision, choice, by) => (decision.action?.kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)),
|
|
728
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
729
|
+
})
|
|
730
|
+
leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
|
|
731
|
+
/** The user prompted the session: its message hop chain starts over (never fails the prompt). */
|
|
732
|
+
const humanTurn = async (sessionID: string): Promise<void> => {
|
|
733
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
734
|
+
if (tenant === undefined) return
|
|
735
|
+
await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
|
|
736
|
+
}
|
|
737
|
+
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
738
|
+
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
739
|
+
/** Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. */
|
|
740
|
+
const actions = createActions({
|
|
741
|
+
registry,
|
|
742
|
+
spokes,
|
|
743
|
+
humanTurn,
|
|
744
|
+
kv: backends.kv,
|
|
745
|
+
// Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
|
|
746
|
+
...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
|
|
747
|
+
})
|
|
748
|
+
leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
|
|
749
|
+
// Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
|
|
750
|
+
// service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
|
|
751
|
+
// console streams get it as a 'launch' event.
|
|
752
|
+
const launchFeed = new LaunchFeed()
|
|
753
|
+
// Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
|
|
754
|
+
// launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
|
|
755
|
+
if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
|
|
756
|
+
launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
|
|
757
|
+
const launches = new LaunchEngine({
|
|
758
|
+
state: backends.state,
|
|
759
|
+
counters: backends.counters,
|
|
760
|
+
locks: backends.locks,
|
|
761
|
+
owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
|
|
762
|
+
...(config.launchCaps ? { caps: config.launchCaps } : {}),
|
|
763
|
+
nodes,
|
|
764
|
+
nodeOps: spokes,
|
|
765
|
+
registry,
|
|
766
|
+
spokes,
|
|
767
|
+
directory,
|
|
768
|
+
delivery,
|
|
769
|
+
decisions,
|
|
770
|
+
actions,
|
|
771
|
+
// openshell launches: the node starts the sandbox's harness with an hlt1 token and the hub's public URL.
|
|
772
|
+
launchTokens,
|
|
773
|
+
hubURL: publicURL,
|
|
774
|
+
users: () => store.users().map((u) => u.id),
|
|
775
|
+
now,
|
|
776
|
+
onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
|
|
777
|
+
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
778
|
+
...(config.cluster
|
|
779
|
+
? {
|
|
780
|
+
supervisor: {
|
|
781
|
+
kick: (userID: string) => void launchLeader.kick({ userID }).catch((cause) => console.error(`[hub] launch kick failed: ${(cause as Error).message}`)),
|
|
782
|
+
stop: async (userID: string, id: string, reason: string) => {
|
|
783
|
+
let reply: { launch?: Launch; error?: { code: AgentErrorCode; message: string } }
|
|
784
|
+
try {
|
|
785
|
+
reply = await launchLeader.request({ op: "stop", userID, id, reason }, { timeoutMs: LAUNCH_STOP_TIMEOUT_MS })
|
|
786
|
+
} catch (cause) {
|
|
787
|
+
if (cause instanceof NoLeaderError) return undefined
|
|
788
|
+
throw cause
|
|
789
|
+
}
|
|
790
|
+
if (reply.error) throw new AgentError(reply.error.code, reply.error.message)
|
|
791
|
+
return reply.launch
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
}
|
|
795
|
+
: {}),
|
|
796
|
+
})
|
|
797
|
+
// A launch-bound connection lives only while its launch is provisioning or running: it is closed (and refused from
|
|
798
|
+
// then on) when the launch ends. Not on queued: that would refuse the sandbox's spoke before it ever connects.
|
|
799
|
+
launchFeed.subscribe((_userID, launch) => {
|
|
800
|
+
if (launch.state === "ended") spokes.closeLaunch(launch.id)
|
|
801
|
+
}, { origin: true })
|
|
802
|
+
const launchLeader = leader("launches", {
|
|
803
|
+
onElected: () => launches.start().catch((cause) => console.error(`[hub] launches did not start: ${(cause as Error).message}`)),
|
|
804
|
+
onDeposed: () => launches.shutdown(),
|
|
805
|
+
onKick: (message) => {
|
|
806
|
+
const userID = (message as { userID?: unknown } | undefined)?.userID
|
|
807
|
+
if (typeof userID === "string") launches.kick(userID)
|
|
808
|
+
},
|
|
809
|
+
onRequest: async (message) => {
|
|
810
|
+
const { op, userID, id, reason } = (message ?? {}) as { op?: unknown; userID?: unknown; id?: unknown; reason?: unknown }
|
|
811
|
+
if (op !== "stop" || typeof userID !== "string" || typeof id !== "string" || typeof reason !== "string") throw new Error("Invalid launch supervisor request")
|
|
812
|
+
try {
|
|
813
|
+
return { launch: await launches.stop(userID, id, reason) }
|
|
814
|
+
} catch (cause) {
|
|
815
|
+
if (cause instanceof AgentError) return { error: { code: cause.code, message: cause.message } }
|
|
816
|
+
throw cause
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
})
|
|
820
|
+
// Webhooks (P4-T10): launch changes go to the owner's matching registrations through the durable outbox; URLs
|
|
821
|
+
// are checked again at every send; private targets only with hub.json agents.webhooks.allowPrivate.
|
|
822
|
+
const webhooks = new Webhooks({
|
|
823
|
+
state: backends.state,
|
|
824
|
+
locks: backends.locks,
|
|
825
|
+
now,
|
|
826
|
+
source: publicURL,
|
|
827
|
+
users: () => store.users().map((u) => u.id),
|
|
828
|
+
resolve: options.resolveHost ?? systemResolveHost,
|
|
829
|
+
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
830
|
+
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
|
|
831
|
+
appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
|
|
832
|
+
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
833
|
+
})
|
|
834
|
+
const webhookLeader = leader("webhooks", {
|
|
835
|
+
onElected: () => webhooks.start(),
|
|
836
|
+
onDeposed: () => webhooks.stop(),
|
|
837
|
+
onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
|
|
838
|
+
})
|
|
839
|
+
// The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
|
|
840
|
+
// presence by the agent-events leader, everything else where it happens (event-writers.ts).
|
|
841
|
+
const agentEvents = new AgentEvents({
|
|
842
|
+
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
843
|
+
kv: backends.kv,
|
|
844
|
+
now,
|
|
845
|
+
onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
|
|
846
|
+
})
|
|
847
|
+
const agentEventWriters = new EventWriters({
|
|
848
|
+
events: agentEvents,
|
|
849
|
+
kv: backends.kv,
|
|
850
|
+
registry,
|
|
851
|
+
machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
|
|
852
|
+
users: allUsers,
|
|
853
|
+
messages: feed,
|
|
854
|
+
launches: launchFeed,
|
|
855
|
+
})
|
|
856
|
+
agentEventWriters.start()
|
|
857
|
+
leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
|
|
858
|
+
for (const loop of leaders) void loop.start()
|
|
859
|
+
|
|
860
|
+
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
861
|
+
// Every stored agent message also goes to the user's console streams (live transcript rows).
|
|
862
|
+
feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
|
|
863
|
+
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
|
|
864
|
+
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
|
|
865
|
+
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
866
|
+
/** An agent as envelopes name it (a session by its directory name). */
|
|
867
|
+
const describeActor = (userID: string, actor: AgentActor) => {
|
|
868
|
+
if (actor.kind !== "session") return partyFromActor(actor)
|
|
869
|
+
try {
|
|
870
|
+
const name = directory.resolve(userID, actor.sessionID).sessions[0]?.name
|
|
871
|
+
return partyFromActor(actor, name ? { name } : {})
|
|
872
|
+
} catch {
|
|
873
|
+
return partyFromActor(actor)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// needs_user escalations (P3-T8): one Decision per (holder, request); answered as the user; closed when the
|
|
877
|
+
// request goes elsewhere.
|
|
878
|
+
// Cluster mode: a recommendation on a request another pod holds goes to that pod.
|
|
879
|
+
const recommendations = config.cluster ? new ClusterRecommendations({ bus: backends.bus, podID: config.cluster.podID, registry }) : undefined
|
|
880
|
+
recommendations?.start().catch((cause) => console.error(`[hub] cluster recommendations did not start: ${(cause as Error).message}`))
|
|
881
|
+
const escalations: Escalations = new Escalations({
|
|
882
|
+
state: backends.state,
|
|
883
|
+
counters: backends.counters,
|
|
884
|
+
decisions,
|
|
885
|
+
registry: {
|
|
886
|
+
sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
887
|
+
recommend: (requestID, recommendation) => (recommendations ?? registry).recommend(requestID, recommendation),
|
|
888
|
+
},
|
|
889
|
+
actions,
|
|
890
|
+
audit,
|
|
891
|
+
now,
|
|
892
|
+
describe: describeActor,
|
|
893
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
894
|
+
})
|
|
895
|
+
// Grant requests (P3-T9): an agent asks; the user's tap creates the grant as the user.
|
|
896
|
+
const grantRequests: GrantRequests = new GrantRequests({
|
|
897
|
+
state: backends.state,
|
|
898
|
+
counters: backends.counters,
|
|
899
|
+
decisions,
|
|
900
|
+
grants,
|
|
901
|
+
directory,
|
|
902
|
+
audit,
|
|
903
|
+
now,
|
|
904
|
+
describe: describeActor,
|
|
905
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
906
|
+
})
|
|
907
|
+
registry.onPermissionClosed((requestID, _sessionID, tenant) => {
|
|
908
|
+
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
909
|
+
})
|
|
910
|
+
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
911
|
+
const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
|
|
912
|
+
const resolveActor = createResolver({
|
|
913
|
+
cookieName: COOKIE,
|
|
914
|
+
findDevice: (token) => deviceOfToken(token),
|
|
915
|
+
user: (id) => store.user(id),
|
|
916
|
+
verifiers: [...agentVerifiers(agentDeps), ...(options.agent?.verifiers ?? [])],
|
|
917
|
+
})
|
|
918
|
+
const agent = agentApi({
|
|
919
|
+
resolve: resolveActor,
|
|
920
|
+
cookieName: COOKIE,
|
|
921
|
+
sameOrigin,
|
|
922
|
+
touch: (user, device) => {
|
|
923
|
+
// Never rejects (lastSeen only).
|
|
924
|
+
if (device) void store.touchDevice(device.id)
|
|
925
|
+
void store.touchUser(user.id)
|
|
926
|
+
},
|
|
927
|
+
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
931
|
+
// to this handler in process.
|
|
932
|
+
const mcp = mcpHttp({
|
|
933
|
+
publicURL: () => publicURL,
|
|
934
|
+
isOwnOrigin: (origin) => origins.has(origin),
|
|
935
|
+
verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
|
|
936
|
+
dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
|
|
937
|
+
version: options.version ?? "0.0.0",
|
|
938
|
+
})
|
|
939
|
+
|
|
379
940
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
380
941
|
const url = new URL(request.url)
|
|
381
942
|
const { pathname } = url
|
|
382
943
|
const method = request.method
|
|
383
944
|
try {
|
|
945
|
+
if (method === "GET" && pathname === "/readyz") return readyz()
|
|
384
946
|
if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
|
|
385
947
|
if (method === "GET" && pathname === "/healthz") {
|
|
386
|
-
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections() })
|
|
948
|
+
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
|
|
387
949
|
}
|
|
950
|
+
// A shared (cluster) store loads its records on first use; the file store is ready at once.
|
|
951
|
+
await store.ready()
|
|
388
952
|
if (method === "POST" && pathname === "/spoke/enroll") {
|
|
389
|
-
const start = auth.startEnrollment((await body(request)).machineName)
|
|
953
|
+
const start = await auth.startEnrollment((await body(request)).machineName)
|
|
390
954
|
const auto = config.auth.mode === "pairing" && localAutoApprove({
|
|
391
955
|
host: config.host,
|
|
392
956
|
publicURL,
|
|
@@ -396,7 +960,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
396
960
|
devices: store.devices().length,
|
|
397
961
|
used: autoApproved,
|
|
398
962
|
})
|
|
399
|
-
if (auto && auth.approveEnrollment(start.userCode)) {
|
|
963
|
+
if (auto && (await auth.approveEnrollment(start.userCode))) {
|
|
400
964
|
autoApproved = true
|
|
401
965
|
console.log(`[hub] local hub: approved the first machine (code ${start.userCode}) without confirmation; later machines need approval`)
|
|
402
966
|
return Response.json({ ...start, autoApproved: true } satisfies EnrollStart)
|
|
@@ -405,25 +969,62 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
405
969
|
return Response.json(start)
|
|
406
970
|
}
|
|
407
971
|
if (method === "POST" && pathname === "/spoke/enroll/poll") {
|
|
408
|
-
const poll = auth.pollEnrollment((await body(request)).deviceCode)
|
|
972
|
+
const poll = await auth.pollEnrollment((await body(request)).deviceCode)
|
|
409
973
|
if (poll.status === "approved") broadcast({ type: "catalog" })
|
|
410
974
|
return Response.json(poll)
|
|
411
975
|
}
|
|
976
|
+
// `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
|
|
977
|
+
if (method === "POST" && pathname === "/cli/login") {
|
|
978
|
+
const input = await body(request)
|
|
979
|
+
return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
|
|
980
|
+
}
|
|
981
|
+
if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
|
|
982
|
+
if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
|
|
983
|
+
if (method === "GET" && pathname === "/.well-known/hub") {
|
|
984
|
+
return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
|
|
985
|
+
}
|
|
412
986
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
413
987
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
414
988
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
989
|
+
if (pathname === MCP_PATH) return await mcp(request)
|
|
990
|
+
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
415
991
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
416
|
-
if (method === "GET") return staticFile(pathname)
|
|
992
|
+
if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
|
|
417
993
|
return error(404, "Not found")
|
|
418
994
|
} catch (cause) {
|
|
419
995
|
return failure(cause)
|
|
420
996
|
}
|
|
421
997
|
}
|
|
422
998
|
|
|
423
|
-
|
|
999
|
+
/**
|
|
1000
|
+
* GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
|
|
1001
|
+
* one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
|
|
1002
|
+
* readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
|
|
1003
|
+
*/
|
|
1004
|
+
async function spokePresence(request: Request): Promise<Response> {
|
|
1005
|
+
const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
|
|
1006
|
+
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
|
|
1007
|
+
if (!token || token.startsWith("hlt1.")) return refused()
|
|
1008
|
+
const machine = await store.findMachine(token)
|
|
1009
|
+
if (machine) await store.recheck({ users: [machine.ownerID] })
|
|
1010
|
+
const owner = machine && store.user(machine.ownerID)
|
|
1011
|
+
if (!machine || machine.revoked || !owner || owner.disabled) return refused()
|
|
1012
|
+
const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
|
|
1013
|
+
const seen = new Set<string>()
|
|
1014
|
+
const spokes = processes.flatMap((p) => {
|
|
1015
|
+
const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
|
|
1016
|
+
const key = JSON.stringify(entry)
|
|
1017
|
+
if (seen.has(key)) return []
|
|
1018
|
+
seen.add(key)
|
|
1019
|
+
return [entry]
|
|
1020
|
+
})
|
|
1021
|
+
return Response.json({ machineID: machine.id, spokes })
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async function login(request: Request, url: URL): Promise<Response> {
|
|
424
1025
|
const next = url.searchParams.get("next") ?? "/"
|
|
425
1026
|
if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
|
|
426
|
-
const ownerID = auth.redeemLogin(url.searchParams.get("code"))
|
|
1027
|
+
const ownerID = await auth.redeemLogin(url.searchParams.get("code"))
|
|
427
1028
|
if (!ownerID) {
|
|
428
1029
|
return new Response("This sign-in link has expired or was already used. Run /hub again.", {
|
|
429
1030
|
status: 401,
|
|
@@ -434,9 +1035,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
434
1035
|
const setup = speechSettings.view(speechScope(ownerID)).ready.reason ? "#speech" : ""
|
|
435
1036
|
const headers = new Headers({ location: `${next}${setup}`, "cache-control": "no-store" })
|
|
436
1037
|
// A link signs in as the machine's owner: a browser signed in as someone else gets a new device.
|
|
437
|
-
if (deviceFor(request)?.user.id !== ownerID) {
|
|
438
|
-
const { device, token } = store.addDevice("Browser (sign-in link)", ownerID)
|
|
439
|
-
store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
|
|
1038
|
+
if ((await deviceFor(request))?.user.id !== ownerID) {
|
|
1039
|
+
const { device, token } = await store.addDevice("Browser (sign-in link)", ownerID)
|
|
1040
|
+
await store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
|
|
440
1041
|
headers.set("set-cookie", cookie(token))
|
|
441
1042
|
}
|
|
442
1043
|
return new Response(null, { status: 302, headers })
|
|
@@ -447,6 +1048,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
447
1048
|
const ownMachine = (userID: string, machineID: string) =>
|
|
448
1049
|
store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
|
|
449
1050
|
|
|
1051
|
+
/** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
|
|
1052
|
+
const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
|
|
1053
|
+
/** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
|
|
1054
|
+
const knownOffline = (userID: string, sessionID: string) =>
|
|
1055
|
+
registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
|
|
450
1056
|
const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
|
|
451
1057
|
const keys: string[] = []
|
|
452
1058
|
const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
|
|
@@ -460,11 +1066,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
460
1066
|
),
|
|
461
1067
|
route("POST", "/api/pair", "public", async ({ request }) => {
|
|
462
1068
|
const input = await body(request)
|
|
463
|
-
const paired = auth.pair(input.code, input.deviceName)
|
|
1069
|
+
const paired = await auth.pair(input.code, input.deviceName)
|
|
464
1070
|
if (!paired) return error(401, "Invalid or expired pairing code")
|
|
465
|
-
store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
|
|
1071
|
+
await store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
|
|
466
1072
|
return Response.json({ device: deviceView(paired.device) }, { headers: { "set-cookie": cookie(paired.token) } })
|
|
467
1073
|
}),
|
|
1074
|
+
// A native app trades a link code for a device token (kept by the app, sent as Authorization: Bearer): no cookie.
|
|
1075
|
+
route("POST", "/api/devices/redeem", "public", async ({ request }) => {
|
|
1076
|
+
const input = await body(request)
|
|
1077
|
+
const linked = await auth.redeemLink(input.code, input.deviceName, input.publicKey)
|
|
1078
|
+
if (!linked) return error(401, "Invalid or expired link code")
|
|
1079
|
+
await store.audit({ actor: linked.device.ownerID, action: "device.pair", target: linked.device.id, detail: "app link" })
|
|
1080
|
+
return Response.json({ device: deviceView(linked.device), token: linked.token }, { headers: NO_STORE })
|
|
1081
|
+
}),
|
|
468
1082
|
|
|
469
1083
|
// any signed-in user, own data only
|
|
470
1084
|
route("GET", "/api/me", "user", ({ device, user }) =>
|
|
@@ -477,21 +1091,36 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
477
1091
|
}),
|
|
478
1092
|
),
|
|
479
1093
|
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
480
|
-
route("GET", "/api/events", "user", ({ request, server, user, device }) => {
|
|
1094
|
+
route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
|
|
481
1095
|
server.timeout(request, 0)
|
|
482
|
-
|
|
1096
|
+
const session = url.searchParams.get("session")
|
|
1097
|
+
return events(request.signal, user.id, device.id, {
|
|
1098
|
+
lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
|
|
1099
|
+
lite: url.searchParams.get("lite") === "1",
|
|
1100
|
+
...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
|
|
1101
|
+
})
|
|
483
1102
|
}),
|
|
484
1103
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
485
1104
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
486
1105
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
487
|
-
const
|
|
1106
|
+
const before = new Set(store.viewPrefs(user.id).hiddenSessions)
|
|
1107
|
+
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
488
1108
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
1109
|
+
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
1110
|
+
// Archive changes made in the console go to the agent event journal like the agent API's.
|
|
1111
|
+
const after = new Set(prefs.hiddenSessions)
|
|
1112
|
+
const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
|
|
1113
|
+
for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
|
|
492
1114
|
return Response.json({ prefs }, { headers: NO_STORE })
|
|
493
1115
|
}),
|
|
494
1116
|
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
1117
|
+
// A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
|
|
1118
|
+
route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
|
|
1119
|
+
const login = await auth.cliLogin(params.code!)
|
|
1120
|
+
return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
|
|
1121
|
+
}),
|
|
1122
|
+
route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
|
|
1123
|
+
route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
|
|
495
1124
|
route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
|
|
496
1125
|
route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
|
|
497
1126
|
route("GET", "/api/devices", "user", ({ device, user }) =>
|
|
@@ -502,23 +1131,33 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
502
1131
|
.map((d) => ({ ...deviceView(d), current: d.id === device.id })),
|
|
503
1132
|
}),
|
|
504
1133
|
),
|
|
505
|
-
|
|
1134
|
+
// A link code for a native app, shown as a QR code / deep link in Settings → Devices → Add app.
|
|
1135
|
+
route("POST", "/api/devices/link", "user", async ({ user }) => {
|
|
1136
|
+
const { code, expiresAt } = auth.issueLinkCode(user.id)
|
|
1137
|
+
await store.audit({ actor: user.id, action: "device.link" })
|
|
1138
|
+
const url = `${APP_SCHEME}://link?hub=${encodeURIComponent(publicURL)}&code=${code}`
|
|
1139
|
+
return Response.json({ code, expiresAt, hub: publicURL, url }, { headers: NO_STORE })
|
|
1140
|
+
}),
|
|
1141
|
+
route("DELETE", "/api/devices/:id", "user", async ({ params, user }) => {
|
|
506
1142
|
const target = store.devices().find((d) => d.id === params.id && d.ownerID === user.id)
|
|
507
|
-
if (!target || !store.removeDevice(target.id)) return error(404, "No such device")
|
|
508
|
-
store.audit({ actor: user.id, action: "device.revoke", target: target.id })
|
|
1143
|
+
if (!target || !await store.removeDevice(target.id)) return error(404, "No such device")
|
|
1144
|
+
await store.audit({ actor: user.id, action: "device.revoke", target: target.id })
|
|
509
1145
|
return new Response(null, { status: 204 })
|
|
510
1146
|
}),
|
|
511
|
-
route("POST", "/api/logout", "user", ({ device }) => {
|
|
512
|
-
store.removeDevice(device.id)
|
|
1147
|
+
route("POST", "/api/logout", "user", async ({ device }) => {
|
|
1148
|
+
await store.removeDevice(device.id)
|
|
513
1149
|
return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
|
|
514
1150
|
}),
|
|
515
1151
|
route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
|
|
516
1152
|
route("POST", "/api/speak", "user", async ({ request, user }) => {
|
|
517
1153
|
const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
|
|
518
1154
|
if (!text) return error(400, "Nothing to speak")
|
|
519
|
-
|
|
1155
|
+
const speech = speechFor(user.id)
|
|
1156
|
+
// Passed through as the provider sends it, so the phone can start playing before synthesis ends.
|
|
1157
|
+
const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
|
|
1158
|
+
return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
|
|
520
1159
|
}),
|
|
521
|
-
route("GET", "/api/push/key", "user", () => Response.json({ publicKey: push.publicKey() })),
|
|
1160
|
+
route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
|
|
522
1161
|
route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
|
|
523
1162
|
const input = await body(request)
|
|
524
1163
|
const target = pushTarget(input.subscription)
|
|
@@ -526,14 +1165,14 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
526
1165
|
const problem = await pushEndpointProblem(target.endpoint, options.resolveHost ?? systemResolveHost)
|
|
527
1166
|
if (problem) return error(400, `Invalid push subscription: ${problem}`)
|
|
528
1167
|
// pushsubscriptionchange: the service worker sends the endpoint it replaces.
|
|
529
|
-
if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) store.removePushSubscription(device.id, input.oldEndpoint)
|
|
530
|
-
store.addPushSubscription(device.id, target)
|
|
1168
|
+
if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) await store.removePushSubscription(device.id, input.oldEndpoint)
|
|
1169
|
+
await store.addPushSubscription(device.id, target)
|
|
531
1170
|
const prefs = prefsInput(input.prefs)
|
|
532
|
-
return Response.json({ prefs: prefs ? store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
|
|
1171
|
+
return Response.json({ prefs: prefs ? await store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
|
|
533
1172
|
}),
|
|
534
1173
|
route("POST", "/api/push/unsubscribe", "user", async ({ request, device }) => {
|
|
535
1174
|
const endpoint = (await body(request)).endpoint
|
|
536
|
-
if (typeof endpoint !== "string" || !store.removePushSubscription(device.id, endpoint)) {
|
|
1175
|
+
if (typeof endpoint !== "string" || !await store.removePushSubscription(device.id, endpoint)) {
|
|
537
1176
|
return error(404, "No such subscription on this device")
|
|
538
1177
|
}
|
|
539
1178
|
return new Response(null, { status: 204 })
|
|
@@ -544,10 +1183,17 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
544
1183
|
if ("quietHours" in input) {
|
|
545
1184
|
const quiet = input.quietHours === null ? null : quietHoursInput(input.quietHours)
|
|
546
1185
|
if (quiet === undefined) return error(400, "quietHours must be {start: \"HH:MM\", end: \"HH:MM\", timeZone} or null")
|
|
547
|
-
store.setQuietHours(device.id, quiet)
|
|
1186
|
+
await store.setQuietHours(device.id, quiet)
|
|
1187
|
+
}
|
|
1188
|
+
if ("activityMinutes" in input) {
|
|
1189
|
+
const minutes = input.activityMinutes
|
|
1190
|
+
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < ACTIVITY_INTERVAL_MIN || minutes > ACTIVITY_INTERVAL_MAX) {
|
|
1191
|
+
return error(400, `activityMinutes must be a whole number of minutes from ${ACTIVITY_INTERVAL_MIN} to ${ACTIVITY_INTERVAL_MAX}`)
|
|
1192
|
+
}
|
|
1193
|
+
await store.setActivityMinutes(device.id, minutes)
|
|
548
1194
|
}
|
|
549
1195
|
const prefs = prefsInput(input)
|
|
550
|
-
if (prefs) store.setPushPrefs(device.id, prefs)
|
|
1196
|
+
if (prefs) await store.setPushPrefs(device.id, prefs)
|
|
551
1197
|
return pushPrefsView(device.id, user.id)
|
|
552
1198
|
}),
|
|
553
1199
|
route("PUT", "/api/push/mute", "user", async ({ request, user }) => {
|
|
@@ -556,12 +1202,15 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
556
1202
|
if (typeof sessionID !== "string" || !sessionID || sessionID.length > 200 || typeof input.muted !== "boolean") {
|
|
557
1203
|
return error(400, "Expected {sessionID, muted}")
|
|
558
1204
|
}
|
|
559
|
-
return Response.json({ muted: store.setSessionMuted(user.id, sessionID, input.muted) })
|
|
1205
|
+
return Response.json({ muted: await store.setSessionMuted(user.id, sessionID, input.muted) })
|
|
560
1206
|
}),
|
|
561
|
-
route("POST", "/api/push/test", "user", async ({ device }) => {
|
|
1207
|
+
route("POST", "/api/push/test", "user", async ({ request, device }) => {
|
|
1208
|
+
// The body is optional (older clients send none): {kind?} picks what the test looks like.
|
|
1209
|
+
const kind = (await body(request).catch(() => ({}) as Record<string, unknown>)).kind
|
|
1210
|
+
if (kind !== undefined && !isNotificationKind(kind)) return error(400, `kind must be one of ${PUSH_PREF_KEYS.join(", ")}`)
|
|
562
1211
|
const subscriptions = store.pushSubscriptions().filter((s) => s.deviceID === device.id).length
|
|
563
1212
|
if (subscriptions === 0) return Response.json({ sent: 0, failed: [], subscriptions })
|
|
564
|
-
const result = await push.test(device.id)
|
|
1213
|
+
const result = await push.test(device.id, kind)
|
|
565
1214
|
if ("limited" in result) {
|
|
566
1215
|
return Response.json({ error: "Wait a few seconds before sending another test" }, { status: 429, headers: { "retry-after": String(result.retryAfter) } })
|
|
567
1216
|
}
|
|
@@ -571,7 +1220,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
571
1220
|
route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
|
|
572
1221
|
// Raw JSON: anything but an object is refused by the validator.
|
|
573
1222
|
route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
|
|
574
|
-
Response.json(speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
|
|
1223
|
+
Response.json(await speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
|
|
575
1224
|
),
|
|
576
1225
|
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
577
1226
|
const kind = url.searchParams.get("kind")
|
|
@@ -605,16 +1254,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
605
1254
|
"/api/sessions/:session/interrupt",
|
|
606
1255
|
"session",
|
|
607
1256
|
async ({ params }) => {
|
|
608
|
-
await spokes.request(ownerOf(params.session
|
|
1257
|
+
await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
|
|
609
1258
|
registry.cancelTurns(params.session!)
|
|
610
1259
|
return new Response(null, { status: 204 })
|
|
611
1260
|
},
|
|
612
1261
|
),
|
|
613
1262
|
route("POST", "/api/sessions/:session/stop", "session", async ({ params }) => {
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
1263
|
+
const stopped = await actions.stop(params.session!)
|
|
1264
|
+
if (stopped.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
1265
|
+
if (stopped.outcome === "unsupported") throw unsupportedError(stopped)
|
|
1266
|
+
return Response.json(stopped.result, { headers: NO_STORE })
|
|
617
1267
|
}),
|
|
1268
|
+
route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
|
|
618
1269
|
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
|
|
619
1270
|
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
620
1271
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
@@ -632,7 +1283,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
632
1283
|
const change = modelPrefsInput(await request.json())
|
|
633
1284
|
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
634
1285
|
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
635
|
-
const prefs = store.setModelPrefs(user.id, params.session!, change)
|
|
1286
|
+
const prefs = await store.setModelPrefs(user.id, params.session!, change)
|
|
636
1287
|
return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
|
|
637
1288
|
}),
|
|
638
1289
|
route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
|
|
@@ -641,7 +1292,35 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
641
1292
|
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
|
|
642
1293
|
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
643
1294
|
if (cursor && cursor.length > 4096) return error(400, "cursor is too long")
|
|
644
|
-
|
|
1295
|
+
// Newest-first mode: order=desc for the newest page, before=<older cursor> for the page before it.
|
|
1296
|
+
const order = url.searchParams.get("order") ?? undefined
|
|
1297
|
+
if (order !== undefined && order !== "asc" && order !== "desc") return error(400, "order must be asc or desc")
|
|
1298
|
+
const before = url.searchParams.get("before") || undefined
|
|
1299
|
+
if (before && before.length > 4096) return error(400, "before is too long")
|
|
1300
|
+
if (cursor && (before || order === "desc")) return error(400, "cursor cannot be combined with before or order=desc")
|
|
1301
|
+
try {
|
|
1302
|
+
const page = await spokes.request(ownerOf(params.session!), {
|
|
1303
|
+
op: "timeline",
|
|
1304
|
+
sessionID: params.session!,
|
|
1305
|
+
...(limit ? { limit } : {}),
|
|
1306
|
+
...(cursor ? { cursor } : {}),
|
|
1307
|
+
...(order ? { order } : {}),
|
|
1308
|
+
...(before ? { before } : {}),
|
|
1309
|
+
})
|
|
1310
|
+
return Response.json(page, { headers: NO_STORE })
|
|
1311
|
+
} catch (cause) {
|
|
1312
|
+
// The row the cursor names is gone (history reverted or compacted): the console starts over.
|
|
1313
|
+
if (cause instanceof SpokeError && cause.message.includes("Invalid timeline cursor")) return error(400, "Invalid timeline cursor")
|
|
1314
|
+
throw cause
|
|
1315
|
+
}
|
|
1316
|
+
}),
|
|
1317
|
+
// Agent messages the session sent or received (newest first), merged into the timeline by `at` (+ watch events).
|
|
1318
|
+
route("GET", "/api/sessions/:session/messages", "session", async ({ params, url, user }) => {
|
|
1319
|
+
const raw = url.searchParams.get("limit")
|
|
1320
|
+
const limit = raw === null ? undefined : Number(raw)
|
|
1321
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
|
|
1322
|
+
const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
|
|
1323
|
+
return Response.json({ messages }, { headers: NO_STORE })
|
|
645
1324
|
}),
|
|
646
1325
|
route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
|
|
647
1326
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
|
|
@@ -662,11 +1341,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
662
1341
|
const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
|
|
663
1342
|
return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
|
|
664
1343
|
}),
|
|
665
|
-
route("POST", "/api/sessions/:session/read", "session", ({ params, user, device }) => {
|
|
666
|
-
const reads = store.markRead(user.id, params.session!, Date.now())
|
|
667
|
-
|
|
668
|
-
if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "reads", reads })
|
|
669
|
-
}
|
|
1344
|
+
route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
|
|
1345
|
+
const reads = await store.markRead(user.id, params.session!, Date.now())
|
|
1346
|
+
toUser(user.id, { type: "reads", reads }, device.id)
|
|
670
1347
|
return Response.json({ reads }, { headers: NO_STORE })
|
|
671
1348
|
}),
|
|
672
1349
|
|
|
@@ -675,7 +1352,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
675
1352
|
route("DELETE", "/api/machines/:machine", "machine", ({ params, user }) => revokeMachine(params.machine!, user)),
|
|
676
1353
|
|
|
677
1354
|
// admins: users, machines, devices, audit, enrollments, the server speech default
|
|
678
|
-
route("GET", "/api/enrollments", "admin", () => Response.json({ enrollments: auth.pendingEnrollments() })),
|
|
1355
|
+
route("GET", "/api/enrollments", "admin", async () => Response.json({ enrollments: await auth.pendingEnrollments() })),
|
|
679
1356
|
route("GET", "/api/admin/users", "admin", () => Response.json(store.users().map(userRow))),
|
|
680
1357
|
route("PUT", "/api/admin/users/:id", "admin", async ({ request, params, user }) => updateUser(params.id!, await body(request), user)),
|
|
681
1358
|
route("GET", "/api/admin/machines", "admin", () => {
|
|
@@ -697,16 +1374,49 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
697
1374
|
)
|
|
698
1375
|
}),
|
|
699
1376
|
route("DELETE", "/api/admin/machines/:id", "admin", ({ params, user }) => revokeMachine(params.id!, user)),
|
|
700
|
-
route("DELETE", "/api/admin/devices/:id", "admin", ({ params, user }) => {
|
|
701
|
-
if (!store.removeDevice(params.id!)) return error(404, "No such device")
|
|
702
|
-
store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
|
|
1377
|
+
route("DELETE", "/api/admin/devices/:id", "admin", async ({ params, user }) => {
|
|
1378
|
+
if (!await store.removeDevice(params.id!)) return error(404, "No such device")
|
|
1379
|
+
await store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
|
|
703
1380
|
return new Response(null, { status: 204 })
|
|
704
1381
|
}),
|
|
705
1382
|
route("GET", "/api/admin/audit", "admin", () => Response.json(store.auditLog())),
|
|
1383
|
+
// The hub-wide agent kill switch: pauses every agent action for every user (reading and messaging still work).
|
|
1384
|
+
route("GET", "/api/admin/agents/acting", "admin", () => Response.json({ enabled: !store.actingPaused() }, { headers: NO_STORE })),
|
|
1385
|
+
route("PUT", "/api/admin/agents/acting", "admin", async ({ request, user }) => {
|
|
1386
|
+
const enabled = (await body(request)).enabled
|
|
1387
|
+
if (typeof enabled !== "boolean") return error(400, "enabled must be true or false")
|
|
1388
|
+
await store.setActingPaused(!enabled)
|
|
1389
|
+
await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
|
|
1390
|
+
return Response.json({ enabled }, { headers: NO_STORE })
|
|
1391
|
+
}),
|
|
1392
|
+
// Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
|
|
1393
|
+
route("GET", "/api/decisions", "user", async ({ url, user }) => {
|
|
1394
|
+
try {
|
|
1395
|
+
return Response.json({ decisions: await decisions.list(user.id, { ...optionalState(url) }) }, { headers: NO_STORE })
|
|
1396
|
+
} catch (cause) {
|
|
1397
|
+
return decisionFailure(cause)
|
|
1398
|
+
}
|
|
1399
|
+
}),
|
|
1400
|
+
route("GET", "/api/decisions/:id", "user", async ({ params, user }) => {
|
|
1401
|
+
const decision = await decisions.get(user.id, params.id!)
|
|
1402
|
+
return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
|
|
1403
|
+
}),
|
|
1404
|
+
route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
|
|
1405
|
+
const input = await body(request)
|
|
1406
|
+
const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
|
|
1407
|
+
try {
|
|
1408
|
+
const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
|
|
1409
|
+
if (answered.reply) feed.publish(user.id, answered.reply)
|
|
1410
|
+
await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer?.option ?? "text" })
|
|
1411
|
+
return Response.json(answered, { headers: NO_STORE })
|
|
1412
|
+
} catch (cause) {
|
|
1413
|
+
return decisionFailure(cause)
|
|
1414
|
+
}
|
|
1415
|
+
}),
|
|
706
1416
|
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
707
1417
|
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
708
|
-
const view = speechSettings.update(await request.json())
|
|
709
|
-
store.audit({ actor: user.id, action: "speech.default" })
|
|
1418
|
+
const view = await speechSettings.update(await request.json())
|
|
1419
|
+
await store.audit({ actor: user.id, action: "speech.default" })
|
|
710
1420
|
return Response.json(view, { headers: NO_STORE })
|
|
711
1421
|
}),
|
|
712
1422
|
]
|
|
@@ -725,7 +1435,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
725
1435
|
|
|
726
1436
|
async function api(request: Request, server: Server<SpokeSocketData>, url: URL): Promise<Response> {
|
|
727
1437
|
const method = request.method
|
|
728
|
-
|
|
1438
|
+
const mode = await resolveActor.mode(request)
|
|
1439
|
+
if (mode === "mixed") return error(400, "Send the device cookie or a bearer token, not both", "invalid")
|
|
1440
|
+
// Bearer requests (device tokens of native apps) are not CSRF-prone; cookie writes must be same-origin (a
|
|
1441
|
+
// foreign bearer next to the cookie, e.g. from an auth proxy, is ignored: still a cookie request).
|
|
1442
|
+
if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !sameOrigin(request) && !credentialFree(request, mode, url)) {
|
|
729
1443
|
return error(403, "Cross-origin request refused")
|
|
730
1444
|
}
|
|
731
1445
|
const found = findRoute(method, url.pathname)
|
|
@@ -734,11 +1448,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
734
1448
|
return await found.route.handle({ request, server, url, params: found.params } as Context)
|
|
735
1449
|
}
|
|
736
1450
|
|
|
737
|
-
const resolved =
|
|
738
|
-
if (!resolved)
|
|
1451
|
+
const resolved = await resolveActor(request)
|
|
1452
|
+
if (!resolved.ok) {
|
|
1453
|
+
if (resolved.reason === "expired") return error(401, "Token expired", "token_expired")
|
|
1454
|
+
return resolved.bearer ? error(401, "Invalid or revoked token", "unauthorized") : error(401, "This device is not paired")
|
|
1455
|
+
}
|
|
1456
|
+
// Console routes are for the user's devices (cookie or device token) only, never session or app tokens.
|
|
1457
|
+
if (resolved.actor.kind !== "user" || !resolved.device) return error(403, "This token cannot be used here", "forbidden")
|
|
739
1458
|
const { device, user } = resolved
|
|
740
|
-
store.touchDevice(device.id)
|
|
741
|
-
store.touchUser(user.id)
|
|
1459
|
+
void store.touchDevice(device.id)
|
|
1460
|
+
void store.touchUser(user.id)
|
|
742
1461
|
if (!found) return error(404, "Not found")
|
|
743
1462
|
|
|
744
1463
|
const { route: matched, params } = found
|
|
@@ -748,7 +1467,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
748
1467
|
break
|
|
749
1468
|
case "session":
|
|
750
1469
|
// Foreign and unknown sessions answer alike, so IDs don't leak.
|
|
751
|
-
if (!registry.owns(user.id, params.session!)) return error(404, "Not found")
|
|
1470
|
+
if (!registry.owns(user.id, params.session!) && !(OFFLINE_SESSION_ROUTES.has(matched.path) && knownOffline(user.id, params.session!))) return error(404, "Not found")
|
|
752
1471
|
// Sessions listed for discovery only: nothing but enabling voice (and presence) reaches them.
|
|
753
1472
|
if (matched.exposed && registry.exposed(params.session!) === false) return error(403, NOT_EXPOSED, "not_exposed")
|
|
754
1473
|
break
|
|
@@ -759,17 +1478,43 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
759
1478
|
return await matched.handle({ request, server, url, params, device, user })
|
|
760
1479
|
}
|
|
761
1480
|
|
|
762
|
-
function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Response {
|
|
763
|
-
const machineName = auth.decide(code, status, user.id)
|
|
1481
|
+
async function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Promise<Response> {
|
|
1482
|
+
const machineName = await auth.decide(code, status, user.id)
|
|
764
1483
|
if (machineName === undefined) return error(404, "No pending enrollment with that code")
|
|
765
|
-
store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
|
|
1484
|
+
await store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
|
|
766
1485
|
broadcast({ type: "enrollment" }, { admins: true })
|
|
767
1486
|
return new Response(null, { status: 204 })
|
|
768
1487
|
}
|
|
769
1488
|
|
|
770
|
-
function
|
|
771
|
-
|
|
772
|
-
|
|
1489
|
+
async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
|
|
1490
|
+
const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
|
|
1491
|
+
if (clientName === undefined) return error(404, "No pending CLI login with that code")
|
|
1492
|
+
await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
|
|
1493
|
+
return new Response(null, { status: 204 })
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
|
|
1498
|
+
* they chose, in this response only (no-store, never logged).
|
|
1499
|
+
*/
|
|
1500
|
+
async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
|
|
1501
|
+
const poll = await auth.pollCliLogin(deviceCode)
|
|
1502
|
+
if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
|
|
1503
|
+
const owner = store.user(poll.ownerID)
|
|
1504
|
+
if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
|
|
1505
|
+
try {
|
|
1506
|
+
const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
|
|
1507
|
+
return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
|
|
1508
|
+
} catch (cause) {
|
|
1509
|
+
if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
|
|
1510
|
+
throw cause
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
|
|
1515
|
+
if (!await store.revokeMachine(id)) return error(404, "No such machine")
|
|
1516
|
+
await store.bumpMachineEpoch(id)
|
|
1517
|
+
await store.audit({ actor: user.id, action: "machine.revoke", target: id })
|
|
773
1518
|
spokes.closeMachine(id, "revoked")
|
|
774
1519
|
broadcast({ type: "catalog" })
|
|
775
1520
|
return new Response(null, { status: 204 })
|
|
@@ -790,7 +1535,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
790
1535
|
}
|
|
791
1536
|
|
|
792
1537
|
/** PUT /api/admin/users/:id {role?, disabled?}: the last active admin can be neither demoted nor disabled. */
|
|
793
|
-
function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Response {
|
|
1538
|
+
async function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Promise<Response> {
|
|
794
1539
|
const target = store.user(id)
|
|
795
1540
|
if (!target) return error(404, "No such user")
|
|
796
1541
|
const { role, disabled } = input
|
|
@@ -802,12 +1547,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
802
1547
|
if (activeAdmin(target) && !activeAdmin(after) && store.users().filter(activeAdmin).length <= 1) {
|
|
803
1548
|
return error(409, "Cannot demote or disable the last active admin. Make someone else an admin first.")
|
|
804
1549
|
}
|
|
805
|
-
if (role && role !== target.role) store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
|
|
1550
|
+
if (role && role !== target.role) await store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
|
|
806
1551
|
if (disabled !== undefined && disabled !== (target.disabled === true)) {
|
|
807
|
-
store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
|
|
1552
|
+
await store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
|
|
808
1553
|
}
|
|
809
|
-
store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
|
|
1554
|
+
await store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
|
|
810
1555
|
if (disabled === true) {
|
|
1556
|
+
await store.bumpUserEpoch(id)
|
|
811
1557
|
// Their machines go offline at once; their browsers get 401 from now on and lose their event streams.
|
|
812
1558
|
for (const machine of store.machines()) if (machine.ownerID === id) spokes.closeMachine(machine.id, "revoked")
|
|
813
1559
|
broadcast({ type: "catalog" })
|
|
@@ -829,26 +1575,30 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
829
1575
|
: undefined
|
|
830
1576
|
if (!text && !images) return error(400, "Empty prompt")
|
|
831
1577
|
if (text.length > MAX_PROMPT_CHARS) return error(400, `Prompt longer than ${MAX_PROMPT_CHARS} characters`)
|
|
832
|
-
|
|
833
|
-
const connID = ownerOf(sessionID)
|
|
834
|
-
// While anything in the tree runs, typed input redirects the current work instead of queueing.
|
|
835
|
-
if (registry.running(sessionID)) {
|
|
836
|
-
const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text, ...attached })
|
|
837
|
-
return Response.json({ mode: "steer", inboxID }, { status: 202 })
|
|
838
|
-
}
|
|
839
|
-
// A stop or interrupt releases the lock and answers this request at once, even if the spoke never does.
|
|
840
|
-
let cancel = () => {}
|
|
841
|
-
const stopped = new Promise<TurnResult>((resolve) => {
|
|
842
|
-
cancel = () => resolve({ outcome: "interrupted", message: "Stopped", reply: "" })
|
|
843
|
-
})
|
|
844
|
-
const release = registry.beginTurn(sessionID, () => cancel())
|
|
1578
|
+
// A turn may run for minutes: this request never times out (a stop or interrupt still answers it at once).
|
|
845
1579
|
server.timeout(request, 0)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
|
|
1580
|
+
const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
1581
|
+
if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
1582
|
+
if (started.outcome === "unsupported") throw unsupportedError(started)
|
|
1583
|
+
if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
|
|
1584
|
+
const result = started.result
|
|
1585
|
+
if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
|
|
1586
|
+
// Asynchronous: answer quick results (and refusals) as usual; a turn still running is accepted (202) and its
|
|
1587
|
+
// result goes to the owner's consoles as an SSE `turn` event (the reply itself streams as deltas meanwhile).
|
|
1588
|
+
const early = await Promise.race([result.then((value) => ({ value }), (cause: unknown) => ({ cause })), sleep(options.timing?.turnAcceptMs ?? TURN_ACCEPT_MS)])
|
|
1589
|
+
if (early) {
|
|
1590
|
+
if ("cause" in early) throw early.cause
|
|
1591
|
+
return Response.json(early.value)
|
|
851
1592
|
}
|
|
1593
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
1594
|
+
void result.then(
|
|
1595
|
+
(value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
|
|
1596
|
+
(cause: unknown) => {
|
|
1597
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
1598
|
+
broadcast({ type: "turn", sessionID, ...(cause instanceof RequestTimeoutError ? { outcome: "timeout" } : { outcome: "failed", message }), reply: "" }, { tenant })
|
|
1599
|
+
},
|
|
1600
|
+
)
|
|
1601
|
+
return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
|
|
852
1602
|
}
|
|
853
1603
|
|
|
854
1604
|
/** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
|
|
@@ -887,10 +1637,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
887
1637
|
}
|
|
888
1638
|
}
|
|
889
1639
|
|
|
890
|
-
/** Connection that owns the session, or a 409
|
|
891
|
-
function ownerOf(sessionID: string): string {
|
|
1640
|
+
/** Connection that owns the session, or a 409; also a 409 (code unsupported) when it lacks the core op `feature`. */
|
|
1641
|
+
function ownerOf(sessionID: string, feature?: CoreOp): string {
|
|
892
1642
|
const connID = registry.owner(sessionID)
|
|
893
1643
|
if (!connID) throw new HttpError(409, NO_OWNER)
|
|
1644
|
+
if (feature && !registry.supports(connID, feature)) throw unsupportedError({ feature, harness: registry.harnessOf(connID) ?? "unknown" })
|
|
894
1645
|
return connID
|
|
895
1646
|
}
|
|
896
1647
|
|
|
@@ -900,11 +1651,61 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
900
1651
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
901
1652
|
if (!text) return error(400, "Empty utterance")
|
|
902
1653
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
903
|
-
const
|
|
1654
|
+
const connID = ownerOf(sessionID, "steer")
|
|
1655
|
+
await humanTurn(sessionID)
|
|
1656
|
+
const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
|
|
904
1657
|
registry.trackUtterance(sessionID, inboxID)
|
|
905
1658
|
return Response.json({ inboxID }, { status: 202 })
|
|
906
1659
|
}
|
|
907
1660
|
|
|
1661
|
+
/** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
|
|
1662
|
+
async function rename(request: Request, sessionID: string): Promise<Response> {
|
|
1663
|
+
const json = await body(request)
|
|
1664
|
+
if (json.auto !== undefined) {
|
|
1665
|
+
if (json.auto !== true) return error(400, "Expected {auto: true} or {title}")
|
|
1666
|
+
return autoName(sessionID)
|
|
1667
|
+
}
|
|
1668
|
+
const raw = json.title
|
|
1669
|
+
const title = typeof raw === "string" ? raw.trim() : ""
|
|
1670
|
+
if (!title) return error(400, "Expected {title}: a non-empty title")
|
|
1671
|
+
if (title.length > MAX_TITLE_CHARS) return error(400, `Title must be at most ${MAX_TITLE_CHARS} characters`)
|
|
1672
|
+
const targets = registry.sessionTargets(sessionID)
|
|
1673
|
+
if (!targets.length) throw new HttpError(409, NO_OWNER)
|
|
1674
|
+
let failure: unknown
|
|
1675
|
+
for (const connID of targets) {
|
|
1676
|
+
try {
|
|
1677
|
+
const result = await spokes.request(connID, { op: "rename", sessionID, title })
|
|
1678
|
+
return Response.json({ title: typeof result?.title === "string" ? result.title : title }, { headers: NO_STORE })
|
|
1679
|
+
} catch (cause) {
|
|
1680
|
+
failure ??= cause
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
throw failure
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Has OpenCode regenerate the title with its title agent, through the processes reporting the session that
|
|
1688
|
+
* support it (older spokes drop the op): 200 {title} or {title, unchanged}, 202 {pending} while generating.
|
|
1689
|
+
*/
|
|
1690
|
+
async function autoName(sessionID: string): Promise<Response> {
|
|
1691
|
+
const reporters = registry.sessionTargets(sessionID)
|
|
1692
|
+
if (!reporters.length) throw new HttpError(409, NO_OWNER)
|
|
1693
|
+
const targets = reporters.filter((connID) => registry.supports(connID, "autoName"))
|
|
1694
|
+
if (!targets.length) throw new HttpError(409, AUTO_NAME_OUTDATED, "plugin_outdated")
|
|
1695
|
+
let failure: unknown
|
|
1696
|
+
for (const connID of targets) {
|
|
1697
|
+
try {
|
|
1698
|
+
const result = await spokes.request(connID, { op: "autoName", sessionID })
|
|
1699
|
+
if (result?.pending) return Response.json({ pending: true }, { status: 202, headers: NO_STORE })
|
|
1700
|
+
if (typeof result?.title !== "string") throw new SpokeError("OpenCode did not report a title")
|
|
1701
|
+
return Response.json({ title: result.title, ...(result.unchanged ? { unchanged: true } : {}) }, { headers: NO_STORE })
|
|
1702
|
+
} catch (cause) {
|
|
1703
|
+
failure ??= cause
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
throw failure
|
|
1707
|
+
}
|
|
1708
|
+
|
|
908
1709
|
async function conversation(request: Request, sessionID: string): Promise<Response> {
|
|
909
1710
|
const on = (await body(request)).on
|
|
910
1711
|
if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
|
|
@@ -968,22 +1769,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
968
1769
|
const input = await body(request)
|
|
969
1770
|
const decision = input.decision as PermissionDecision
|
|
970
1771
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
971
|
-
const
|
|
972
|
-
if (
|
|
973
|
-
|
|
974
|
-
let failure: unknown
|
|
975
|
-
for (const target of targets) {
|
|
976
|
-
try {
|
|
977
|
-
const result = await spokes.request(target.connID, { op: "permission", sessionID: target.sessionID, requestID: input.requestID, decision })
|
|
978
|
-
if (!result?.gone) return new Response(null, { status: 204 })
|
|
979
|
-
} catch (cause) {
|
|
980
|
-
// Older spokes pass OpenCode's "Permission request not found" through as an error.
|
|
981
|
-
if (!/not found/i.test(cause instanceof Error ? cause.message : String(cause))) failure ??= cause
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
if (failure) throw failure
|
|
985
|
-
registry.dropPermission(input.requestID)
|
|
986
|
-
return error(409, PERMISSION_GONE, "gone")
|
|
1772
|
+
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
1773
|
+
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
1774
|
+
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
987
1775
|
}
|
|
988
1776
|
|
|
989
1777
|
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
@@ -996,23 +1784,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
996
1784
|
const problem = formAnswerProblem(input.answer)
|
|
997
1785
|
if (problem) return error(400, problem)
|
|
998
1786
|
}
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
? { op: "form" as const, sessionID: target.sessionID, formID: input.formID, cancel: true as const }
|
|
1005
|
-
: { op: "form" as const, sessionID: target.sessionID, formID: input.formID, answer: input.answer as FormAnswer }
|
|
1006
|
-
try {
|
|
1007
|
-
const result = await spokes.request(target.connID, req)
|
|
1008
|
-
if (!result?.gone) return new Response(null, { status: 204 })
|
|
1009
|
-
} catch (cause) {
|
|
1010
|
-
failure ??= cause
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
if (failure) throw failure
|
|
1014
|
-
registry.dropForm(input.formID)
|
|
1015
|
-
return error(409, PERMISSION_GONE, "gone")
|
|
1787
|
+
const answered = await actions.answerForm(
|
|
1788
|
+
cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
|
|
1789
|
+
)
|
|
1790
|
+
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
1791
|
+
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1016
1792
|
}
|
|
1017
1793
|
|
|
1018
1794
|
async function transcribe(request: Request, speech: Speech): Promise<Response> {
|
|
@@ -1027,7 +1803,14 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1027
1803
|
return Response.json({ text: await speech.transcribe(audio, format) })
|
|
1028
1804
|
}
|
|
1029
1805
|
|
|
1030
|
-
|
|
1806
|
+
/**
|
|
1807
|
+
* The live stream. Every message has an id; with `lastID` (Last-Event-ID or ?last=) the hub replays what came
|
|
1808
|
+
* after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
|
|
1809
|
+
* over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
|
|
1810
|
+
*/
|
|
1811
|
+
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
|
|
1812
|
+
// Draining: come back (to another pod) shortly.
|
|
1813
|
+
if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
|
|
1031
1814
|
const encoder = new TextEncoder()
|
|
1032
1815
|
let cleanup = () => {}
|
|
1033
1816
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -1042,17 +1825,38 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1042
1825
|
const listener: Listener = {
|
|
1043
1826
|
userID,
|
|
1044
1827
|
deviceID,
|
|
1045
|
-
|
|
1828
|
+
lite: options.lite,
|
|
1829
|
+
...(options.session ? { session: options.session } : {}),
|
|
1830
|
+
send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
|
|
1046
1831
|
close: () => {
|
|
1047
1832
|
cleanup()
|
|
1048
1833
|
try {
|
|
1049
1834
|
controller.close()
|
|
1050
1835
|
} catch {}
|
|
1051
1836
|
},
|
|
1837
|
+
end: (retryMs) => {
|
|
1838
|
+
write(`retry: ${retryMs}\n\n`)
|
|
1839
|
+
listener.close()
|
|
1840
|
+
},
|
|
1841
|
+
}
|
|
1842
|
+
if (live) return clusterStream(listener, write)
|
|
1843
|
+
eventLog.open(userID)
|
|
1844
|
+
const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
|
|
1845
|
+
if (missed) {
|
|
1846
|
+
let catalog = false
|
|
1847
|
+
for (const { id, message, exceptDevice } of missed) {
|
|
1848
|
+
if (exceptDevice === deviceID) continue
|
|
1849
|
+
if (message.type === "catalog") catalog = true
|
|
1850
|
+
else if (wants(listener, message)) listener.send(message, id)
|
|
1851
|
+
}
|
|
1852
|
+
if (catalog) listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
|
|
1853
|
+
} else {
|
|
1854
|
+
listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
|
|
1855
|
+
if (options.lastID) listener.send({ type: "resync" }, eventLog.latest(userID))
|
|
1052
1856
|
}
|
|
1053
|
-
listener.send({ type: "catalog", machines: machines(userID) })
|
|
1054
1857
|
listeners.add(listener)
|
|
1055
|
-
|
|
1858
|
+
// A named event (not a comment) so the page sees it and can tell a live stream from a dead one.
|
|
1859
|
+
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
|
|
1056
1860
|
cleanup = () => {
|
|
1057
1861
|
clearInterval(heartbeat)
|
|
1058
1862
|
listeners.delete(listener)
|
|
@@ -1063,12 +1867,104 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1063
1867
|
cleanup()
|
|
1064
1868
|
},
|
|
1065
1869
|
})
|
|
1870
|
+
/**
|
|
1871
|
+
* Cluster mode: the user's live events from every pod; the replay comes from the shared EventLog. Events that
|
|
1872
|
+
* arrive while it is read wait (in order) and then go out, except those the replay already sent.
|
|
1873
|
+
*/
|
|
1874
|
+
async function clusterStream(listener: Listener, write: (chunk: string) => void): Promise<void> {
|
|
1875
|
+
let pending: LiveEvent[] | undefined = []
|
|
1876
|
+
const replayed = new Set<string>()
|
|
1877
|
+
const forward = (event: LiveEvent) => {
|
|
1878
|
+
if (event.exceptDevice === deviceID || (event.id !== undefined && replayed.has(event.id))) return
|
|
1879
|
+
if (wants(listener, event.message)) listener.send(event.message, event.id)
|
|
1880
|
+
}
|
|
1881
|
+
const subscription = live!.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
|
|
1882
|
+
listeners.add(listener)
|
|
1883
|
+
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
|
|
1884
|
+
cleanup = () => {
|
|
1885
|
+
clearInterval(heartbeat)
|
|
1886
|
+
listeners.delete(listener)
|
|
1887
|
+
subscription.close()
|
|
1888
|
+
}
|
|
1889
|
+
signal.addEventListener("abort", () => cleanup(), { once: true })
|
|
1890
|
+
try {
|
|
1891
|
+
await subscription.ready
|
|
1892
|
+
const missed = options.lastID ? await backends.events.since(userID, options.lastID) : undefined
|
|
1893
|
+
if (missed && "events" in missed) {
|
|
1894
|
+
for (const { id, message, exceptDevice } of missed.events) {
|
|
1895
|
+
replayed.add(id)
|
|
1896
|
+
if (exceptDevice !== deviceID && wants(listener, message)) listener.send(message, id)
|
|
1897
|
+
}
|
|
1898
|
+
// Catalogs are not logged in a cluster: a resumed stream always gets the current one.
|
|
1899
|
+
listener.send({ type: "catalog", machines: machines(userID) })
|
|
1900
|
+
} else {
|
|
1901
|
+
const latest = await backends.events.latest(userID)
|
|
1902
|
+
listener.send({ type: "catalog", machines: machines(userID) }, latest)
|
|
1903
|
+
if (options.lastID) listener.send({ type: "resync" }, latest)
|
|
1904
|
+
}
|
|
1905
|
+
} catch (cause) {
|
|
1906
|
+
console.error(`[hub] live stream did not start: ${(cause as Error).message}`)
|
|
1907
|
+
listener.close()
|
|
1908
|
+
return
|
|
1909
|
+
}
|
|
1910
|
+
const waiting = pending
|
|
1911
|
+
pending = undefined
|
|
1912
|
+
for (const event of waiting) forward(event)
|
|
1913
|
+
}
|
|
1066
1914
|
return new Response(stream, {
|
|
1067
1915
|
headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
|
|
1068
1916
|
})
|
|
1069
1917
|
}
|
|
1070
1918
|
|
|
1071
|
-
|
|
1919
|
+
/** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
|
|
1920
|
+
async function readyz(): Promise<Response> {
|
|
1921
|
+
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
1922
|
+
if (config.cluster) {
|
|
1923
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1924
|
+
const late = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error("timed out")), READY_CHECK_MS)))
|
|
1925
|
+
try {
|
|
1926
|
+
await Promise.race([Promise.all([backends.state.ping?.(), backends.kv.ping?.()]), late])
|
|
1927
|
+
} catch {
|
|
1928
|
+
// A public endpoint: no backend detail (hosts, users) in the answer.
|
|
1929
|
+
return Response.json({ ok: false, error: "database or Valkey not reachable" }, { status: 503, headers: NO_STORE })
|
|
1930
|
+
} finally {
|
|
1931
|
+
clearTimeout(timer)
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
return Response.json({ ok: true }, { headers: NO_STORE })
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function runDrain(drainOptions: DrainOptions): Promise<void> {
|
|
1938
|
+
const timeoutMs = drainOptions.timeoutMs ?? options.timing?.drainTimeoutMs ?? DRAIN_TIMEOUT_MS
|
|
1939
|
+
const retryAfterMs = drainOptions.retryAfterMs ?? options.timing?.drainRetryAfterMs ?? DRAIN_RETRY_AFTER_MS
|
|
1940
|
+
// Refuse new WebSockets and requests; bye each connection once its requests in flight are answered.
|
|
1941
|
+
const connections = spokes.drain({ timeoutMs, retryAfterMs })
|
|
1942
|
+
// Step down at once (releasing the leases), so another pod takes the timed jobs over.
|
|
1943
|
+
await Promise.allSettled(leaders.map((loop) => loop.stop()))
|
|
1944
|
+
// Withdraw this pod's slices: the other pods stop showing its connections (the new pod republishes them).
|
|
1945
|
+
await view?.stop().catch((cause) => console.error(`[hub] cluster view not withdrawn: ${(cause as Error).message}`))
|
|
1946
|
+
for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
|
|
1947
|
+
drainSignal.abort()
|
|
1948
|
+
await connections
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/** Compressed copies of hashed assets, made once per file and encoding (they never change). */
|
|
1952
|
+
const compressed = new Map<string, Promise<Uint8Array>>()
|
|
1953
|
+
function compress(name: string, bytes: Uint8Array, encoding: "br" | "gzip"): Promise<Uint8Array> {
|
|
1954
|
+
const key = `${encoding} ${name}`
|
|
1955
|
+
let done = compressed.get(key)
|
|
1956
|
+
if (!done) {
|
|
1957
|
+
done =
|
|
1958
|
+
encoding === "br"
|
|
1959
|
+
? brotli(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 6, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
|
|
1960
|
+
: gzipAsync(bytes, { level: 9 })
|
|
1961
|
+
done.catch(() => compressed.delete(key))
|
|
1962
|
+
compressed.set(key, done)
|
|
1963
|
+
}
|
|
1964
|
+
return done
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
|
|
1072
1968
|
const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
|
|
1073
1969
|
const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
|
|
1074
1970
|
const bytes = name && type ? web.read(name) : undefined
|
|
@@ -1082,6 +1978,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1082
1978
|
headers.set("service-worker-allowed", "/")
|
|
1083
1979
|
} else if (type.startsWith("text/html")) {
|
|
1084
1980
|
headers.set("cache-control", "no-cache")
|
|
1981
|
+
} else if (HASHED_ASSET.test(name)) {
|
|
1982
|
+
headers.set("cache-control", IMMUTABLE)
|
|
1983
|
+
if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
|
|
1984
|
+
headers.set("vary", "Accept-Encoding")
|
|
1985
|
+
const encoding = pickEncoding(acceptEncoding)
|
|
1986
|
+
if (encoding) {
|
|
1987
|
+
headers.set("content-encoding", encoding)
|
|
1988
|
+
return new Response(new Uint8Array(await compress(name, bytes, encoding)), { headers })
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1085
1991
|
}
|
|
1086
1992
|
return new Response(bytes, { headers })
|
|
1087
1993
|
}
|
|
@@ -1092,20 +1998,88 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1092
1998
|
store,
|
|
1093
1999
|
auth,
|
|
1094
2000
|
registry,
|
|
2001
|
+
nodes,
|
|
1095
2002
|
spokes,
|
|
1096
2003
|
push,
|
|
1097
2004
|
speechSettings,
|
|
1098
|
-
|
|
2005
|
+
backends,
|
|
2006
|
+
delivery,
|
|
2007
|
+
decisions,
|
|
2008
|
+
acting,
|
|
2009
|
+
launches,
|
|
2010
|
+
launchFeed,
|
|
2011
|
+
launchTokens,
|
|
2012
|
+
webhooks,
|
|
2013
|
+
agentEvents,
|
|
2014
|
+
agentEventWriters,
|
|
2015
|
+
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
2016
|
+
drain(drainOptions: DrainOptions = {}) {
|
|
2017
|
+
draining ??= runDrain(drainOptions)
|
|
2018
|
+
return draining
|
|
2019
|
+
},
|
|
1099
2020
|
async stop() {
|
|
1100
|
-
|
|
2021
|
+
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
2022
|
+
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
2023
|
+
launches.shutdown()
|
|
2024
|
+
webhooks.stop()
|
|
2025
|
+
agentEventWriters.stop()
|
|
2026
|
+
tokenPush.stop()
|
|
2027
|
+
delivery.stop()
|
|
2028
|
+
decisions.stop()
|
|
2029
|
+
await spokes.stop()
|
|
2030
|
+
await view?.stop().catch((cause) => console.error(`[hub] cluster view not stopped: ${(cause as Error).message}`))
|
|
1101
2031
|
for (const listener of [...listeners]) listeners.delete(listener)
|
|
2032
|
+
await Promise.all([live?.stop(), feed.detach(), launchFeed.detach()]).catch((cause) => console.error(`[hub] live fan-out not stopped: ${(cause as Error).message}`))
|
|
1102
2033
|
// Bun 1.3.5 quirk: once the server has closed a WebSocket itself, stop(true)'s promise never
|
|
1103
2034
|
// resolves even though the listener is closed. Don't wait on it forever.
|
|
1104
2035
|
await Promise.race([server.stop(true), Bun.sleep(250)])
|
|
2036
|
+
await backends.state.flush?.().catch((cause) => console.error(`[hub] agent state not saved: ${(cause as Error).message}`))
|
|
2037
|
+
await store.close()
|
|
2038
|
+
if (ownBackends) await backends.close?.().catch((cause) => console.error(`[hub] backends not closed: ${(cause as Error).message}`))
|
|
1105
2039
|
},
|
|
1106
2040
|
}
|
|
1107
2041
|
}
|
|
1108
2042
|
|
|
2043
|
+
/** The state filter of GET /api/decisions (throws invalid). */
|
|
2044
|
+
const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
2045
|
+
const state = decisionStateQuery(url.searchParams.get("state"))
|
|
2046
|
+
return state ? { state } : {}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** {option?, text?} of a Decision answer (types only; Decisions validates). */
|
|
2050
|
+
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
|
|
2051
|
+
for (const key of ["option", "text"] as const) {
|
|
2052
|
+
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
2053
|
+
}
|
|
2054
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
|
|
2058
|
+
function decisionFailure(cause: unknown): Response {
|
|
2059
|
+
if (!(cause instanceof AgentError)) throw cause
|
|
2060
|
+
const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
|
|
2061
|
+
return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
|
|
2065
|
+
export const APP_SCHEME = "hubapp"
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* Public writes a native app makes with no ambient credential at all (no cookie, no bearer, no Origin or
|
|
2069
|
+
* Referer): not CSRF-prone, the single-use code in the body is the credential. Every other write keeps the
|
|
2070
|
+
* same-origin check, and so does any request carrying a cookie or a browser Origin.
|
|
2071
|
+
*/
|
|
2072
|
+
const CREDENTIAL_FREE = new Set(["POST /api/devices/redeem"])
|
|
2073
|
+
|
|
2074
|
+
function credentialFree(request: Request, mode: CredentialMode, url: URL): boolean {
|
|
2075
|
+
return (
|
|
2076
|
+
mode === "none" &&
|
|
2077
|
+
!request.headers.has("origin") &&
|
|
2078
|
+
!request.headers.has("referer") &&
|
|
2079
|
+
CREDENTIAL_FREE.has(`${request.method} ${url.pathname}`)
|
|
2080
|
+
)
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1109
2083
|
function userView(user: UserRecord) {
|
|
1110
2084
|
return { id: user.id, ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), role: user.role }
|
|
1111
2085
|
}
|
|
@@ -1134,11 +2108,11 @@ function pushTarget(value: unknown): { endpoint: string; keys: { p256dh: string;
|
|
|
1134
2108
|
return { endpoint, keys: { p256dh, auth } }
|
|
1135
2109
|
}
|
|
1136
2110
|
|
|
1137
|
-
/** Boolean pref fields (and
|
|
2111
|
+
/** Boolean pref fields (and the legacy `blocked` and `finished`) from untrusted input; undefined when nothing applies. */
|
|
1138
2112
|
function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | undefined {
|
|
1139
2113
|
if (typeof value !== "object" || value === null) return undefined
|
|
1140
2114
|
const prefs: Partial<PushPrefs> & LegacyPushPrefs = {}
|
|
1141
|
-
for (const key of [...PUSH_PREF_KEYS, "blocked"] as const) {
|
|
2115
|
+
for (const key of [...PUSH_PREF_KEYS, "blocked", "finished"] as const) {
|
|
1142
2116
|
const v = (value as Record<string, unknown>)[key]
|
|
1143
2117
|
if (typeof v === "boolean") prefs[key] = v
|
|
1144
2118
|
}
|
|
@@ -1148,7 +2122,10 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
|
|
|
1148
2122
|
/** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
|
|
1149
2123
|
function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
1150
2124
|
const invalid = () =>
|
|
1151
|
-
new HttpError(
|
|
2125
|
+
new HttpError(
|
|
2126
|
+
400,
|
|
2127
|
+
`Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} (hiddenSessions: ${VIEW_PREF_MAX_ARCHIVED}) strings of at most ${VIEW_PREF_MAX_CHARS} characters`,
|
|
2128
|
+
)
|
|
1152
2129
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
|
|
1153
2130
|
const record = value as Record<string, unknown>
|
|
1154
2131
|
if (Object.keys(record).some((key) => !(VIEW_PREF_KEYS as readonly string[]).includes(key))) throw invalid()
|
|
@@ -1156,7 +2133,7 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
|
1156
2133
|
for (const key of VIEW_PREF_KEYS) {
|
|
1157
2134
|
const list = record[key]
|
|
1158
2135
|
if (list === undefined) continue
|
|
1159
|
-
if (!Array.isArray(list) || list.length > VIEW_PREF_MAX_ENTRIES) throw invalid()
|
|
2136
|
+
if (!Array.isArray(list) || list.length > (key === "hiddenSessions" ? VIEW_PREF_MAX_ARCHIVED : VIEW_PREF_MAX_ENTRIES)) throw invalid()
|
|
1160
2137
|
if (!list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= VIEW_PREF_MAX_CHARS)) throw invalid()
|
|
1161
2138
|
out[key] = [...new Set(list as string[])]
|
|
1162
2139
|
}
|
|
@@ -1192,6 +2169,8 @@ function param(raw: string): string {
|
|
|
1192
2169
|
}
|
|
1193
2170
|
}
|
|
1194
2171
|
|
|
2172
|
+
const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
|
|
2173
|
+
|
|
1195
2174
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
1196
2175
|
const value = (await request.json()) as unknown
|
|
1197
2176
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|