@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// The spoke's single outbound WebSocket to the central server: hello/welcome handshake, request/
|
|
2
|
+
// response correlation in both directions, and reconnect with jittered exponential backoff. A bye carrying
|
|
3
|
+
// retryAfterMs (a hub pod draining, design §10) is followed by one short jittered reconnect (0–retryAfterMs) instead.
|
|
4
|
+
import {
|
|
5
|
+
encode,
|
|
6
|
+
MAX_FRAME,
|
|
7
|
+
parseServerMessage,
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
type Hello,
|
|
10
|
+
type ResultFor,
|
|
11
|
+
type ServerRequest,
|
|
12
|
+
type SpokeMessage,
|
|
13
|
+
type SpokeRequest,
|
|
14
|
+
type Welcome,
|
|
15
|
+
} from "../../protocol/src/index.ts"
|
|
16
|
+
|
|
17
|
+
export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
|
|
18
|
+
|
|
19
|
+
export interface Backoff {
|
|
20
|
+
readonly minMs: number
|
|
21
|
+
readonly maxMs: number
|
|
22
|
+
readonly random?: () => number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
|
|
26
|
+
export interface SocketLike {
|
|
27
|
+
readonly readyState: number
|
|
28
|
+
onopen: ((ev: unknown) => void) | null
|
|
29
|
+
onmessage: ((ev: { data: unknown }) => void) | null
|
|
30
|
+
onclose: ((ev: { code?: number; reason?: string }) => void) | null
|
|
31
|
+
onerror: ((ev: unknown) => void) | null
|
|
32
|
+
send(data: string): void
|
|
33
|
+
close(code?: number, reason?: string): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ConnectionOptions {
|
|
37
|
+
readonly server: string
|
|
38
|
+
readonly token: string
|
|
39
|
+
/** Hello fields, computed per attempt so mode/directory are current. */
|
|
40
|
+
readonly hello: () => Omit<Hello, "t" | "v">
|
|
41
|
+
/** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
|
|
42
|
+
readonly handle: (req: ServerRequest) => Promise<unknown>
|
|
43
|
+
readonly onWelcome?: (welcome: Welcome) => void
|
|
44
|
+
readonly onState?: (state: ConnectionState, error?: string) => void
|
|
45
|
+
readonly backoff?: Backoff
|
|
46
|
+
readonly requestTimeoutMs?: number
|
|
47
|
+
readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function backoffDelay(attempt: number, backoff: Backoff): number {
|
|
51
|
+
const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
|
|
52
|
+
const random = (backoff.random ?? Math.random)()
|
|
53
|
+
return Math.round(base * (0.5 + 0.5 * random))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The reconnect delay a bye's retryAfterMs asks for: uniformly jittered over 0–retryAfterMs, so peers spread out. */
|
|
57
|
+
export function retryAfterDelay(retryAfterMs: number, random: () => number = Math.random): number {
|
|
58
|
+
return Math.round(retryAfterMs * random())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function wsURL(server: string): string {
|
|
62
|
+
return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
|
|
66
|
+
new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
|
|
67
|
+
|
|
68
|
+
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
69
|
+
|
|
70
|
+
interface Pending {
|
|
71
|
+
resolve: (value: unknown) => void
|
|
72
|
+
reject: (error: Error) => void
|
|
73
|
+
timer: ReturnType<typeof setTimeout>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class SpokeConnection {
|
|
77
|
+
state: ConnectionState = "off"
|
|
78
|
+
lastError?: string
|
|
79
|
+
publicURL?: string
|
|
80
|
+
machineID?: string
|
|
81
|
+
|
|
82
|
+
private socket?: SocketLike
|
|
83
|
+
private welcomed = false
|
|
84
|
+
/** Features the hub listed in its welcome on the current socket (none before a welcome or from an old hub). */
|
|
85
|
+
private hubFeatures: ReadonlySet<string> = new Set()
|
|
86
|
+
private stopped = true
|
|
87
|
+
private attempt = 0
|
|
88
|
+
private retry?: ReturnType<typeof setTimeout>
|
|
89
|
+
/** The last bye's retryAfterMs (a draining hub): the next reconnect waits about this instead of the backoff. */
|
|
90
|
+
private retryAfterMs?: number
|
|
91
|
+
private nextID = 0
|
|
92
|
+
private readonly pending = new Map<string, Pending>()
|
|
93
|
+
private readonly backoff: Backoff
|
|
94
|
+
|
|
95
|
+
constructor(private readonly options: ConnectionOptions) {
|
|
96
|
+
this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
start(): void {
|
|
100
|
+
if (!this.stopped) return
|
|
101
|
+
this.stopped = false
|
|
102
|
+
this.open()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stop(): void {
|
|
106
|
+
this.stopped = true
|
|
107
|
+
clearTimeout(this.retry)
|
|
108
|
+
this.drop(1000, "stop")
|
|
109
|
+
this.failPending("disconnected")
|
|
110
|
+
this.setState("off")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the connected hub announced `feature`; send a request a newer hub added only when it did. */
|
|
114
|
+
hubSupports(feature: string): boolean {
|
|
115
|
+
return this.hubFeatures.has(feature)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
|
|
119
|
+
retryNow(): void {
|
|
120
|
+
if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
|
|
121
|
+
clearTimeout(this.retry)
|
|
122
|
+
this.attempt = 0
|
|
123
|
+
this.open()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
|
|
127
|
+
send(frame: SpokeMessage): boolean {
|
|
128
|
+
if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
|
|
129
|
+
try {
|
|
130
|
+
let text = encode(frame)
|
|
131
|
+
if (text.length > MAX_FRAME) {
|
|
132
|
+
// The hub drops the connection on an oversize frame; answer a request with an error instead.
|
|
133
|
+
if (frame.t !== "res") return false
|
|
134
|
+
text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
|
|
135
|
+
}
|
|
136
|
+
this.socket.send(text)
|
|
137
|
+
return true
|
|
138
|
+
} catch {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
|
|
144
|
+
const id = `s${++this.nextID}`
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
this.pending.delete(id)
|
|
149
|
+
reject(new Error("The hub timed out"))
|
|
150
|
+
}, this.options.requestTimeoutMs ?? 15_000)
|
|
151
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
|
|
152
|
+
if (!this.send({ t: "req", id, req })) {
|
|
153
|
+
clearTimeout(timer)
|
|
154
|
+
this.pending.delete(id)
|
|
155
|
+
reject(new Error("Not connected to the hub"))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private open(): void {
|
|
161
|
+
if (this.stopped) return
|
|
162
|
+
this.setState(this.state === "error" ? "error" : "connecting")
|
|
163
|
+
let socket: SocketLike
|
|
164
|
+
try {
|
|
165
|
+
socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
|
|
166
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
167
|
+
})
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
this.fail(message(cause))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
this.socket = socket
|
|
173
|
+
this.welcomed = false
|
|
174
|
+
socket.onopen = () => {
|
|
175
|
+
try {
|
|
176
|
+
socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
this.lastError = message(cause)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
socket.onmessage = (ev) => this.receive(ev.data)
|
|
182
|
+
socket.onerror = () => {
|
|
183
|
+
this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
|
|
184
|
+
}
|
|
185
|
+
socket.onclose = (ev) => {
|
|
186
|
+
if (this.socket !== socket) return
|
|
187
|
+
this.socket = undefined
|
|
188
|
+
const wasWelcomed = this.welcomed
|
|
189
|
+
this.welcomed = false
|
|
190
|
+
this.hubFeatures = new Set()
|
|
191
|
+
this.failPending("disconnected")
|
|
192
|
+
if (this.stopped || this.state === "revoked") return
|
|
193
|
+
this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private receive(data: unknown): void {
|
|
198
|
+
const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
|
|
199
|
+
const frame = parseServerMessage(text)
|
|
200
|
+
if (!frame) return
|
|
201
|
+
switch (frame.t) {
|
|
202
|
+
case "welcome":
|
|
203
|
+
this.welcomed = true
|
|
204
|
+
this.hubFeatures = new Set(frame.features ?? [])
|
|
205
|
+
this.attempt = 0
|
|
206
|
+
this.publicURL = frame.publicURL
|
|
207
|
+
this.machineID = frame.machineID
|
|
208
|
+
this.lastError = undefined
|
|
209
|
+
this.setState("connected")
|
|
210
|
+
try {
|
|
211
|
+
this.options.onWelcome?.(frame)
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
console.error("[hub] welcome handler failed", cause)
|
|
214
|
+
}
|
|
215
|
+
return
|
|
216
|
+
case "bye":
|
|
217
|
+
if (frame.reason === "revoked") {
|
|
218
|
+
this.stopped = true
|
|
219
|
+
clearTimeout(this.retry)
|
|
220
|
+
this.lastError = "This machine was revoked on the hub"
|
|
221
|
+
this.setState("revoked")
|
|
222
|
+
this.drop(1000, "revoked")
|
|
223
|
+
this.failPending("revoked")
|
|
224
|
+
} else if (frame.retryAfterMs !== undefined) {
|
|
225
|
+
this.retryAfterMs = frame.retryAfterMs
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
case "res": {
|
|
229
|
+
const pending = this.pending.get(frame.id)
|
|
230
|
+
if (!pending) return
|
|
231
|
+
this.pending.delete(frame.id)
|
|
232
|
+
clearTimeout(pending.timer)
|
|
233
|
+
if (frame.ok) pending.resolve(frame.result)
|
|
234
|
+
else pending.reject(new Error(frame.error ?? "Request failed"))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
case "req":
|
|
238
|
+
void this.answer(frame.id, frame.req)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async answer(id: string, req: ServerRequest): Promise<void> {
|
|
244
|
+
let reply: SpokeMessage
|
|
245
|
+
try {
|
|
246
|
+
reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
reply = { t: "res", id, ok: false, error: message(cause) }
|
|
249
|
+
}
|
|
250
|
+
this.send(reply)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private fail(error: string): void {
|
|
254
|
+
this.lastError = error
|
|
255
|
+
this.setState("error")
|
|
256
|
+
if (this.stopped) return
|
|
257
|
+
clearTimeout(this.retry)
|
|
258
|
+
const retryAfterMs = this.retryAfterMs
|
|
259
|
+
this.retryAfterMs = undefined
|
|
260
|
+
const delay = retryAfterMs !== undefined ? retryAfterDelay(retryAfterMs, this.backoff.random) : backoffDelay(this.attempt++, this.backoff)
|
|
261
|
+
this.retry = setTimeout(() => this.open(), delay)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private drop(code: number, reason: string): void {
|
|
265
|
+
const socket = this.socket
|
|
266
|
+
this.socket = undefined
|
|
267
|
+
this.welcomed = false
|
|
268
|
+
this.hubFeatures = new Set()
|
|
269
|
+
if (!socket) return
|
|
270
|
+
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
|
|
271
|
+
try {
|
|
272
|
+
socket.close(code, reason)
|
|
273
|
+
} catch {
|
|
274
|
+
// already closed
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private failPending(reason: string): void {
|
|
279
|
+
for (const [id, pending] of this.pending) {
|
|
280
|
+
clearTimeout(pending.timer)
|
|
281
|
+
pending.reject(new Error(`Hub ${reason}`))
|
|
282
|
+
this.pending.delete(id)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private setState(state: ConnectionState): void {
|
|
287
|
+
if (state === this.state) return
|
|
288
|
+
this.state = state
|
|
289
|
+
try {
|
|
290
|
+
this.options.onState?.(state, this.lastError)
|
|
291
|
+
} catch {
|
|
292
|
+
// observers must not break the connection
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Harness-neutral delivery helpers (the deliver op): the provenance envelope an agent's prompt (kind "prompt") is
|
|
2
|
+
// rendered in, who put each inbox item into a session, and applying each hub message once (DeliveryLedger: the hub
|
|
3
|
+
// retries a deliver with the same messageID; a harness may not deduplicate it, so the spoke must).
|
|
4
|
+
import { sanitizeLabel, sanitizeText } from "../../sdk/src/envelope.ts"
|
|
5
|
+
import type { DeliverMeta, DeliverResult, InboxOrigin } from "../../protocol/src/index.ts"
|
|
6
|
+
import { PROMPT_SOURCE } from "./prompt-source.ts"
|
|
7
|
+
|
|
8
|
+
/** Body lines that could pass for a header or boundary (or an escaped one): escaped with a leading backslash. */
|
|
9
|
+
const FORGEABLE = /^\s*\\*(?:\[hub\]|-{3,}|↩)/i
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The text a session sees for an agent's prompt (kind "prompt"): a provenance header naming the sender and the
|
|
13
|
+
* grant, then the sanitised body between two boundaries (the same shape as the hub's message envelope, so
|
|
14
|
+
* timelines read the body back). The header comes from the hub's meta, never from the body.
|
|
15
|
+
*/
|
|
16
|
+
export function renderPromptEnvelope(input: { readonly messageID: string; readonly from: string; readonly grant: string; readonly text: string }): string {
|
|
17
|
+
const id = sanitizeLabel(input.messageID)
|
|
18
|
+
const boundary = `----- ${id} -----`
|
|
19
|
+
const body = sanitizeText(input.text)
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map((line) => (FORGEABLE.test(line) ? `\\${line}` : line))
|
|
22
|
+
.join("\n")
|
|
23
|
+
return [`[hub] prompt from ${sanitizeLabel(input.from)} · grant ${sanitizeLabel(input.grant)} · ${id}`, boundary, body, boundary].join("\n")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What OpenCode gets for a deliver: a prompt wrapped in its provenance envelope, other kinds as the hub rendered them. */
|
|
27
|
+
export function deliverText(req: { readonly messageID: string; readonly text: string; readonly meta: DeliverMeta }): string {
|
|
28
|
+
if (req.meta.kind !== "prompt") return req.text
|
|
29
|
+
return renderPromptEnvelope({ messageID: req.messageID, from: req.meta.from, grant: req.meta.grant ?? "", text: req.text })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
|
|
33
|
+
|
|
34
|
+
/** A bounded insertion-ordered map: the oldest entry goes when it is full. */
|
|
35
|
+
class Bounded<V> {
|
|
36
|
+
private readonly map = new Map<string, V>()
|
|
37
|
+
constructor(private readonly max: number) {}
|
|
38
|
+
get(key: string): V | undefined {
|
|
39
|
+
return this.map.get(key)
|
|
40
|
+
}
|
|
41
|
+
set(key: string, value: V): void {
|
|
42
|
+
this.map.delete(key)
|
|
43
|
+
this.map.set(key, value)
|
|
44
|
+
if (this.map.size > this.max) this.map.delete(this.map.keys().next().value!)
|
|
45
|
+
}
|
|
46
|
+
delete(key: string): void {
|
|
47
|
+
this.map.delete(key)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Who put each inbox item into a session, from OpenCode's session.inbox.enqueued events and the spoke's own
|
|
53
|
+
* deliveries: a user item is the user's (typed, or sent from the phone), a synthetic item with hub metadata is
|
|
54
|
+
* the hub's; anything else (OpenCode's own notes, compaction) has no origin.
|
|
55
|
+
*/
|
|
56
|
+
export class InboxOrigins {
|
|
57
|
+
private readonly origins: Bounded<InboxOrigin>
|
|
58
|
+
constructor(max = 1000) {
|
|
59
|
+
this.origins = new Bounded(max)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
observe(event: { readonly type: string; readonly data?: unknown }): void {
|
|
63
|
+
if (event.type !== "session.inbox.enqueued") return
|
|
64
|
+
const data = record(event.data)
|
|
65
|
+
if (typeof data.inboxID !== "string" || this.origins.get(data.inboxID) === "hub") return
|
|
66
|
+
const item = record(data.item)
|
|
67
|
+
const metadata = record(record(item.payload).metadata)
|
|
68
|
+
// A hub message (hub metadata with its messageID) is never the user's, whatever item type OpenCode gives it.
|
|
69
|
+
if (metadata.source === PROMPT_SOURCE && typeof metadata.messageID === "string" && (item.type === "synthetic" || item.type === "user")) this.origins.set(data.inboxID, "hub")
|
|
70
|
+
else if (item.type === "user") this.origins.set(data.inboxID, "user")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Marks an inbox item the spoke delivered for the hub. */
|
|
74
|
+
hub(inboxID: string): void {
|
|
75
|
+
this.origins.set(inboxID, "hub")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The item's origin, when known; forgets it (an item is delivered once). */
|
|
79
|
+
take(inboxID: string): InboxOrigin | undefined {
|
|
80
|
+
const origin = this.origins.get(inboxID)
|
|
81
|
+
this.origins.delete(inboxID)
|
|
82
|
+
return origin
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Admission {
|
|
87
|
+
readonly result: DeliverResult
|
|
88
|
+
/** Whether it was sent with resume (could start a turn). */
|
|
89
|
+
readonly resumed: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Applies each hub message once: a duplicate messageID (the hub's retry, in flight or done) gets the first
|
|
94
|
+
* result without another admission. Only a retry that asks to wake a message first admitted without waking is
|
|
95
|
+
* sent again (OpenCode keeps one message for the id and wakes the session). Failures are not remembered.
|
|
96
|
+
*/
|
|
97
|
+
export class DeliveryLedger {
|
|
98
|
+
private readonly entries: Bounded<Promise<Admission>>
|
|
99
|
+
constructor(max = 1000) {
|
|
100
|
+
this.entries = new Bounded(max)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async run(messageID: string, wake: boolean, send: () => Promise<Admission>): Promise<DeliverResult> {
|
|
104
|
+
const known = this.entries.get(messageID)
|
|
105
|
+
if (known) {
|
|
106
|
+
const admission = await known.catch(() => undefined)
|
|
107
|
+
if (admission && (!wake || admission.resumed)) return admission.result
|
|
108
|
+
if (this.entries.get(messageID) !== known) return this.run(messageID, wake, send)
|
|
109
|
+
}
|
|
110
|
+
const attempt = send()
|
|
111
|
+
this.entries.set(messageID, attempt)
|
|
112
|
+
try {
|
|
113
|
+
return (await attempt).result
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
if (this.entries.get(messageID) === attempt) this.entries.delete(messageID)
|
|
116
|
+
throw cause
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Device-code enrollment of this machine with the central server (before it has a token).
|
|
2
|
+
// POST /spoke/enroll → EnrollStart; then POST /spoke/enroll/poll until the user approves it.
|
|
3
|
+
import type { EnrollPoll, EnrollStart } from "../../protocol/src/index.ts"
|
|
4
|
+
|
|
5
|
+
export type EnrollOutcome =
|
|
6
|
+
| { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
|
|
7
|
+
| { readonly status: "denied" | "expired" | "cancelled" }
|
|
8
|
+
|
|
9
|
+
export interface EnrollOptions {
|
|
10
|
+
readonly signal?: AbortSignal
|
|
11
|
+
readonly fetch?: typeof fetch
|
|
12
|
+
readonly now?: () => number
|
|
13
|
+
/**
|
|
14
|
+
* "follow" (default) follows HTTP redirects. "error" refuses them: a redirect could send the device code to
|
|
15
|
+
* another origin or downgrade to plain http. A refused redirect throws EnrollmentRefused, which polling does not
|
|
16
|
+
* retry.
|
|
17
|
+
*/
|
|
18
|
+
readonly redirect?: "follow" | "error"
|
|
19
|
+
/**
|
|
20
|
+
* The hub hands out an approved credential exactly once. With keepRedeemed, `signal` never aborts a poll request
|
|
21
|
+
* in flight (its reply may carry that credential); it still stops polling before the next request.
|
|
22
|
+
*/
|
|
23
|
+
readonly keepRedeemed?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The hub answered in a way the caller's policy refuses (a redirect under redirect: "error"); not retried. */
|
|
27
|
+
export class EnrollmentRefused extends Error {
|
|
28
|
+
override readonly name = "EnrollmentRefused"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
32
|
+
|
|
33
|
+
async function post(server: string, path: string, body: unknown, options: EnrollOptions, abortable = true): Promise<unknown> {
|
|
34
|
+
const doFetch = options.fetch ?? fetch
|
|
35
|
+
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal && abortable ? [options.signal] : [])]
|
|
36
|
+
const strict = options.redirect === "error"
|
|
37
|
+
const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
signal: AbortSignal.any(signals),
|
|
42
|
+
...(strict ? { redirect: "manual" as const } : {}),
|
|
43
|
+
})
|
|
44
|
+
if (strict && (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400))) {
|
|
45
|
+
throw new EnrollmentRefused(`${path} was redirected; enrollment does not follow redirects`)
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
|
|
48
|
+
return response.json()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isStart(v: any): v is EnrollStart {
|
|
52
|
+
return (
|
|
53
|
+
typeof v === "object" &&
|
|
54
|
+
v !== null &&
|
|
55
|
+
typeof v.userCode === "string" &&
|
|
56
|
+
typeof v.deviceCode === "string" &&
|
|
57
|
+
typeof v.approveURL === "string" &&
|
|
58
|
+
typeof v.intervalMs === "number" &&
|
|
59
|
+
typeof v.expiresAt === "number"
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
|
|
64
|
+
const body = await post(server, "/spoke/enroll", { machineName }, options)
|
|
65
|
+
if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
|
|
66
|
+
return body
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sleep = (ms: number, signal?: AbortSignal) =>
|
|
70
|
+
new Promise<void>((resolve) => {
|
|
71
|
+
const timer = setTimeout(done, ms)
|
|
72
|
+
function done() {
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
signal?.removeEventListener("abort", done)
|
|
75
|
+
resolve()
|
|
76
|
+
}
|
|
77
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
|
|
81
|
+
export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
|
|
82
|
+
const now = options.now ?? Date.now
|
|
83
|
+
const interval = Math.max(1, start.intervalMs)
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (options.signal?.aborted) return { status: "cancelled" }
|
|
86
|
+
if (now() > start.expiresAt) return { status: "expired" }
|
|
87
|
+
await sleep(interval, options.signal)
|
|
88
|
+
if (options.signal?.aborted) return { status: "cancelled" }
|
|
89
|
+
let reply: Partial<EnrollPoll> & Record<string, unknown>
|
|
90
|
+
try {
|
|
91
|
+
reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options, !options.keepRedeemed)) as typeof reply
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
if (cause instanceof EnrollmentRefused) throw cause
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
|
|
97
|
+
return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
|
|
98
|
+
}
|
|
99
|
+
if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The hello of a spoke for a harness other than OpenCode (design §1.1, P7-T1): its harness id, version and evidence
|
|
2
|
+
// level, and every feature it serves. Unlike an OpenCode hello it implies nothing: each core op (CORE_OPS) the spoke
|
|
3
|
+
// serves is announced, and the hub sends only announced ops. Refuses what would mislead the hub instead of sending it.
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import {
|
|
6
|
+
CORE_OPS,
|
|
7
|
+
EVIDENCE_LEVELS,
|
|
8
|
+
HARNESS_OPENCODE,
|
|
9
|
+
KNOWN_SPOKE_FEATURES,
|
|
10
|
+
type CoreOp,
|
|
11
|
+
type EvidenceLevel,
|
|
12
|
+
type Hello,
|
|
13
|
+
type ProcessKind,
|
|
14
|
+
type SpokeFeature,
|
|
15
|
+
type SpokeMode,
|
|
16
|
+
} from "../../protocol/src/index.ts"
|
|
17
|
+
|
|
18
|
+
export interface HarnessHelloOptions {
|
|
19
|
+
/** The harness id: a lowercase token (letters, digits, `.`, `_`, `-`), not "opencode". */
|
|
20
|
+
readonly harness: string
|
|
21
|
+
readonly harnessVersion: string
|
|
22
|
+
/** How the spoke observes activity; the hub never treats a session as idle on weaker evidence than it claims. */
|
|
23
|
+
readonly evidence: EvidenceLevel
|
|
24
|
+
/** The spoke's own version. */
|
|
25
|
+
readonly pluginVersion: string
|
|
26
|
+
readonly directory: string
|
|
27
|
+
/** The core ops the spoke serves (a hooks spoke may serve "permission" but not "turn"). */
|
|
28
|
+
readonly ops: readonly CoreOp[]
|
|
29
|
+
/** The optional features it serves (e.g. "deliver", "sessionTokens"); never a core op. */
|
|
30
|
+
readonly features?: readonly SpokeFeature[]
|
|
31
|
+
readonly processID?: string
|
|
32
|
+
readonly kind?: ProcessKind
|
|
33
|
+
readonly mode?: SpokeMode
|
|
34
|
+
readonly pid?: number
|
|
35
|
+
readonly launchID?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The hello's harness id rule (as the hub parses it). */
|
|
39
|
+
const HARNESS = /^[a-z0-9][a-z0-9._-]*$/
|
|
40
|
+
const HARNESS_MAX = 64
|
|
41
|
+
const VERSION_MAX = 100
|
|
42
|
+
const CORE: readonly string[] = CORE_OPS
|
|
43
|
+
const KNOWN: readonly string[] = KNOWN_SPOKE_FEATURES
|
|
44
|
+
|
|
45
|
+
const unique = <T>(values: readonly T[]): T[] => [...new Set(values)]
|
|
46
|
+
|
|
47
|
+
/** The hello fields (without `t` and `v`, which the connection adds) for a non-OpenCode spoke. Throws on bad options. */
|
|
48
|
+
export function buildHarnessHello(options: HarnessHelloOptions): Omit<Hello, "t" | "v"> {
|
|
49
|
+
const { harness, harnessVersion, evidence } = options
|
|
50
|
+
if (harness === HARNESS_OPENCODE) throw new Error("A harness spoke cannot be harness \"opencode\": OpenCode hellos imply every core op; use the OpenCode spoke")
|
|
51
|
+
if (typeof harness !== "string" || harness.length > HARNESS_MAX || !HARNESS.test(harness)) {
|
|
52
|
+
throw new Error(`The harness id must be 1–${HARNESS_MAX} lowercase letters, digits, ".", "_" or "-" (got ${JSON.stringify(harness)})`)
|
|
53
|
+
}
|
|
54
|
+
if (typeof harnessVersion !== "string" || harnessVersion.length > VERSION_MAX) throw new Error(`The harness version must be text of at most ${VERSION_MAX} characters`)
|
|
55
|
+
if (!(EVIDENCE_LEVELS as readonly string[]).includes(evidence)) throw new Error(`The evidence level must be one of ${EVIDENCE_LEVELS.join(", ")} (got ${JSON.stringify(evidence)})`)
|
|
56
|
+
for (const op of options.ops) if (!CORE.includes(op)) throw new Error(`Unknown core op ${JSON.stringify(op)} (core ops: ${CORE_OPS.join(", ")})`)
|
|
57
|
+
const features = unique(options.features ?? [])
|
|
58
|
+
for (const feature of features) {
|
|
59
|
+
if (CORE.includes(feature)) throw new Error(`"${feature}" is a core op: list it in ops, not features`)
|
|
60
|
+
if (!KNOWN.includes(feature)) throw new Error(`Unknown spoke feature ${JSON.stringify(feature)}`)
|
|
61
|
+
}
|
|
62
|
+
if (features.includes("hubPrompt") && !features.includes("deliver")) throw new Error("The hubPrompt feature needs the deliver feature")
|
|
63
|
+
const ops = CORE_OPS.filter((op) => options.ops.includes(op))
|
|
64
|
+
return {
|
|
65
|
+
processID: options.processID ?? `${harness.replace(/[^A-Za-z0-9_-]/g, "_")}_${randomBytes(8).toString("hex")}`,
|
|
66
|
+
kind: options.kind ?? "service",
|
|
67
|
+
directory: options.directory,
|
|
68
|
+
pid: options.pid ?? process.pid,
|
|
69
|
+
mode: options.mode ?? "always",
|
|
70
|
+
// Hubs that predate `harness` require opencodeVersion from every spoke (protocol Hello).
|
|
71
|
+
opencodeVersion: "",
|
|
72
|
+
pluginVersion: options.pluginVersion,
|
|
73
|
+
features: [...ops, ...features],
|
|
74
|
+
harness,
|
|
75
|
+
harnessVersion,
|
|
76
|
+
evidence,
|
|
77
|
+
...(options.launchID !== undefined ? { launchID: options.launchID } : {}),
|
|
78
|
+
}
|
|
79
|
+
}
|