@huynle/hub 0.6.1 → 0.7.1
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 +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -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 +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -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 +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -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 +60 -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 +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -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 +79 -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 +142 -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 +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -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 +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -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 +244 -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 +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- 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 +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- 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 +68 -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/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -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/index.html +9 -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,102 @@
|
|
|
1
|
+
// Webhook verification for receivers (design §6.1; decision znbg8xa5 #8). The hub signs every webhook POST with
|
|
2
|
+
// Hub-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
|
|
3
|
+
// (format: packages/protocol/src/webhook.ts). verifyWebhook checks it against the receiver's secret(s) in constant
|
|
4
|
+
// time and refuses timestamps outside the tolerance (replay). Verify the raw body bytes as received, before parsing.
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
6
|
+
|
|
7
|
+
// The format, as in packages/protocol/src/webhook.ts (the SDK imports nothing outside its package; the tests check
|
|
8
|
+
// the two agree).
|
|
9
|
+
export const WEBHOOK_SIGNATURE_HEADER = "Hub-Signature"
|
|
10
|
+
export const WEBHOOK_EVENT_HEADER = "Hub-Event"
|
|
11
|
+
export const WEBHOOK_DELIVERY_HEADER = "Hub-Delivery"
|
|
12
|
+
/** Largest gap, either way, between a signature's timestamp and the receiver's clock. */
|
|
13
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300
|
|
14
|
+
const SCHEME = "v1"
|
|
15
|
+
const ALGORITHM = "sha256"
|
|
16
|
+
const HEADER_MAX = 2048
|
|
17
|
+
const HEX_SHA256 = /^[0-9a-f]{64}$/
|
|
18
|
+
|
|
19
|
+
/** A Hub-Signature header's timestamp (unix seconds) and v1 signatures, or undefined when it is malformed. */
|
|
20
|
+
function parseSignature(value: string): { t: number; v1: string[] } | undefined {
|
|
21
|
+
if (value.length > HEADER_MAX) return undefined
|
|
22
|
+
let t: number | undefined
|
|
23
|
+
const v1: string[] = []
|
|
24
|
+
for (const part of value.split(",")) {
|
|
25
|
+
const eq = part.indexOf("=")
|
|
26
|
+
if (eq < 0) return undefined
|
|
27
|
+
const key = part.slice(0, eq).trim()
|
|
28
|
+
const v = part.slice(eq + 1).trim()
|
|
29
|
+
if (key === "t") {
|
|
30
|
+
if (t !== undefined || !/^\d{1,12}$/.test(v)) return undefined
|
|
31
|
+
t = Number(v)
|
|
32
|
+
} else if (key === SCHEME) {
|
|
33
|
+
if (!HEX_SHA256.test(v)) return undefined
|
|
34
|
+
v1.push(v)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return t !== undefined && v1.length > 0 ? { t, v1 } : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Request headers: a Headers, or a plain object such as node's IncomingMessage.headers (any key case). */
|
|
41
|
+
export type WebhookHeaders = Headers | Readonly<Record<string, string | readonly string[] | undefined>>
|
|
42
|
+
|
|
43
|
+
export interface VerifyWebhookInput {
|
|
44
|
+
/** The raw request body, exactly as received. */
|
|
45
|
+
readonly body: string | Uint8Array
|
|
46
|
+
/** The Hub-Signature header value (or give `headers`). */
|
|
47
|
+
readonly signature?: string | null
|
|
48
|
+
/** The request's headers: Hub-Signature, and Hub-Event / Hub-Delivery for the answer. */
|
|
49
|
+
readonly headers?: WebhookHeaders
|
|
50
|
+
/** The registration's secret; several while one rotates (any may match). */
|
|
51
|
+
readonly secret: string | readonly string[]
|
|
52
|
+
/** The receiver's clock in ms (default Date.now()). */
|
|
53
|
+
readonly now?: number
|
|
54
|
+
/** Seconds a timestamp may be off either way (default WEBHOOK_TOLERANCE_SECONDS). */
|
|
55
|
+
readonly tolerance?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type WebhookVerification =
|
|
59
|
+
| { readonly ok: true; readonly timestamp: number; readonly event?: string; readonly delivery?: string }
|
|
60
|
+
| { readonly ok: false; readonly reason: "missing" | "malformed" | "stale" | "mismatch" }
|
|
61
|
+
|
|
62
|
+
function header(headers: WebhookHeaders | undefined, name: string): string | undefined {
|
|
63
|
+
if (!headers) return undefined
|
|
64
|
+
if (typeof (headers as Headers).get === "function") return (headers as Headers).get(name) ?? undefined
|
|
65
|
+
const wanted = name.toLowerCase()
|
|
66
|
+
for (const [key, value] of Object.entries(headers as Record<string, string | readonly string[] | undefined>)) {
|
|
67
|
+
if (key.toLowerCase() !== wanted || value === undefined) continue
|
|
68
|
+
return typeof value === "string" ? value : value[0]
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const text = (body: string | Uint8Array) => (typeof body === "string" ? body : new TextDecoder().decode(body))
|
|
74
|
+
|
|
75
|
+
const hmac = (secret: string, t: number, body: string): Buffer => createHmac(ALGORITHM, secret).update(`${t}.${body}`).digest()
|
|
76
|
+
|
|
77
|
+
/** Whether a Hub-Signature matches the body under one of the secrets, and is fresh. */
|
|
78
|
+
export function verifyWebhook(input: VerifyWebhookInput): WebhookVerification {
|
|
79
|
+
const raw = input.signature ?? header(input.headers, WEBHOOK_SIGNATURE_HEADER)
|
|
80
|
+
if (raw === undefined || raw === null || raw === "") return { ok: false, reason: "missing" }
|
|
81
|
+
const parsed = parseSignature(raw)
|
|
82
|
+
if (!parsed) return { ok: false, reason: "malformed" }
|
|
83
|
+
if (Math.abs((input.now ?? Date.now()) / 1000 - parsed.t) > (input.tolerance ?? WEBHOOK_TOLERANCE_SECONDS)) return { ok: false, reason: "stale" }
|
|
84
|
+
const body = text(input.body)
|
|
85
|
+
const secrets = (typeof input.secret === "string" ? [input.secret] : input.secret).filter((s) => s.length > 0)
|
|
86
|
+
const given = parsed.v1.map((hex) => Buffer.from(hex, "hex"))
|
|
87
|
+
// Every comparison runs (no early exit), each in constant time.
|
|
88
|
+
let match = false
|
|
89
|
+
for (const secret of secrets) {
|
|
90
|
+
const expected = hmac(secret, parsed.t, body)
|
|
91
|
+
for (const signature of given) if (signature.length === expected.length && timingSafeEqual(signature, expected)) match = true
|
|
92
|
+
}
|
|
93
|
+
if (!match) return { ok: false, reason: "mismatch" }
|
|
94
|
+
const event = header(input.headers, WEBHOOK_EVENT_HEADER)
|
|
95
|
+
const delivery = header(input.headers, WEBHOOK_DELIVERY_HEADER)
|
|
96
|
+
return { ok: true, timestamp: parsed.t, ...(event !== undefined ? { event } : {}), ...(delivery !== undefined ? { delivery } : {}) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The Hub-Signature header the hub would send for `body` (for testing receivers); `t` in unix seconds, default now. */
|
|
100
|
+
export function signWebhook(body: string | Uint8Array, secret: string, t: number = Math.floor(Date.now() / 1000)): string {
|
|
101
|
+
return `t=${t},${SCHEME}=${hmac(secret, t, text(body)).toString("hex")}`
|
|
102
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Activity compaction (tool inputs cut to short summaries, no outputs) is part of the wire format and lives in the
|
|
2
|
+
// protocol package; the spoke SDK re-exports it so a spoke needs one import.
|
|
3
|
+
export { compactActivity, compactStatus, jobFromInfo, summarizeToolInput } from "../../protocol/src/index.ts"
|
|
@@ -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,79 @@
|
|
|
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
|
+
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
16
|
+
|
|
17
|
+
async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
|
|
18
|
+
const doFetch = options.fetch ?? fetch
|
|
19
|
+
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
|
|
20
|
+
const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.any(signals),
|
|
25
|
+
})
|
|
26
|
+
if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
|
|
27
|
+
return response.json()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isStart(v: any): v is EnrollStart {
|
|
31
|
+
return (
|
|
32
|
+
typeof v === "object" &&
|
|
33
|
+
v !== null &&
|
|
34
|
+
typeof v.userCode === "string" &&
|
|
35
|
+
typeof v.deviceCode === "string" &&
|
|
36
|
+
typeof v.approveURL === "string" &&
|
|
37
|
+
typeof v.intervalMs === "number" &&
|
|
38
|
+
typeof v.expiresAt === "number"
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
|
|
43
|
+
const body = await post(server, "/spoke/enroll", { machineName }, options)
|
|
44
|
+
if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
|
|
45
|
+
return body
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sleep = (ms: number, signal?: AbortSignal) =>
|
|
49
|
+
new Promise<void>((resolve) => {
|
|
50
|
+
const timer = setTimeout(done, ms)
|
|
51
|
+
function done() {
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
signal?.removeEventListener("abort", done)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
|
|
60
|
+
export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
|
|
61
|
+
const now = options.now ?? Date.now
|
|
62
|
+
const interval = Math.max(1, start.intervalMs)
|
|
63
|
+
for (;;) {
|
|
64
|
+
if (options.signal?.aborted) return { status: "cancelled" }
|
|
65
|
+
if (now() > start.expiresAt) return { status: "expired" }
|
|
66
|
+
await sleep(interval, options.signal)
|
|
67
|
+
if (options.signal?.aborted) return { status: "cancelled" }
|
|
68
|
+
let reply: Partial<EnrollPoll> & Record<string, unknown>
|
|
69
|
+
try {
|
|
70
|
+
reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
|
|
71
|
+
} catch {
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
|
|
75
|
+
return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
|
|
76
|
+
}
|
|
77
|
+
if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
|
|
78
|
+
}
|
|
79
|
+
}
|