@huynle/hub 0.8.3 → 0.8.5

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/protocol/src/index.ts +7 -2
  53. package/packages/runtime-openshell/package.json +1 -1
  54. package/packages/runtime-sdk/package.json +1 -1
  55. package/packages/sdk/package.json +1 -1
  56. package/packages/spoke-sdk/package.json +1 -1
  57. package/src/central/config.ts +12 -0
  58. package/src/central/http.ts +10 -0
  59. package/src/central/install/CONTRACT.md +66 -0
  60. package/src/central/install/install.ps1 +349 -0
  61. package/src/central/install/install.sh +266 -0
  62. package/src/central/install/render.ts +146 -0
  63. package/src/central/install/templates.ts +6 -0
  64. package/src/central/install/text-assets.d.ts +9 -0
  65. package/src/central/registry.ts +93 -22
  66. package/src/config.ts +12 -7
  67. package/src/index.ts +28 -0
  68. package/src/spoke/env-inject.ts +17 -2
  69. package/src/spoke/launchers.ts +145 -9
  70. package/src/spoke/spoke.ts +150 -10
  71. package/src/status.ts +70 -14
  72. package/src/win-acl.ts +154 -0
  73. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  74. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
  75. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  76. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  77. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  78. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
  79. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  80. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  81. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  82. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  83. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  84. package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
  85. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  86. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  87. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
  88. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
  89. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
  90. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  91. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  92. package/web/dist/index.html +6 -4
  93. package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
  94. package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
  95. package/web/dist/assets/Library-DTLEyekn.js +0 -2
  96. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  97. package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
  98. package/web/dist/assets/index-6mdQ7eUY.js +0 -34
  99. package/web/dist/assets/index-C5OmwA1y.css +0 -1
@@ -0,0 +1,266 @@
1
+ #!/bin/sh
2
+ # Hub machine setup for macOS and Linux, served by the hub as GET /install.sh (see CONTRACT.md):
3
+ #
4
+ # curl -fsSL '<hub>/install.sh?<choices>' | sh
5
+ #
6
+ # Downloads the hubctl release this hub pins, checks its sha256, enrolls this machine with the hub (device code,
7
+ # approved in the console), then runs `hubctl setup preview`, `setup apply` and `setup verify` for the chosen
8
+ # profile. Nothing runs until the last line: a truncated download only defines functions. Every child process reads
9
+ # /dev/null, never the script on stdin. Re-running it is safe: an enrolled machine is not enrolled again, and apply
10
+ # converges.
11
+
12
+ hub_say() {
13
+ printf 'hub setup: %s\n' "$*" >&2
14
+ }
15
+
16
+ # hub_die <exit code> <message...>
17
+ hub_die() {
18
+ hub_code=$1
19
+ shift
20
+ hub_say "$*"
21
+ exit "$hub_code"
22
+ }
23
+
24
+ hub_has() {
25
+ command -v "$1" >/dev/null 2>&1
26
+ }
27
+
28
+ hub_is_musl() {
29
+ if ldd --version 2>&1 </dev/null | grep -qi musl; then
30
+ return 0
31
+ fi
32
+ for hub_f in /lib/ld-musl-* /usr/lib/ld-musl-*; do
33
+ if [ -e "$hub_f" ]; then
34
+ return 0
35
+ fi
36
+ done
37
+ return 1
38
+ }
39
+
40
+ # Sets HUB_TARGET (darwin-arm64, darwin-x64, linux-x64, linux-arm64) or stops with the reason.
41
+ hub_target() {
42
+ hub_os=$(uname -s 2>/dev/null || echo unknown)
43
+ hub_arch=$(uname -m 2>/dev/null || echo unknown)
44
+ case "$hub_os" in
45
+ Darwin) hub_os=darwin ;;
46
+ Linux) hub_os=linux ;;
47
+ *) hub_die 7 "this system ($hub_os) is not supported: use macOS or Linux (on Windows, use the PowerShell command)" ;;
48
+ esac
49
+ case "$hub_arch" in
50
+ x86_64 | amd64) hub_arch=x64 ;;
51
+ arm64 | aarch64) hub_arch=arm64 ;;
52
+ *) hub_die 7 "this processor ($hub_arch) is not supported: hubctl is built for x86_64 and arm64" ;;
53
+ esac
54
+ if [ "$hub_os" = linux ]; then
55
+ if hub_is_musl; then
56
+ hub_die 7 "this Linux uses musl libc (Alpine?), which hubctl does not support yet: use a glibc distribution"
57
+ fi
58
+ if [ -r /proc/version ] && grep -qi microsoft /proc/version && ! grep -qi wsl2 /proc/version; then
59
+ hub_die 7 "this is WSL 1, which hubctl does not support: convert the distribution to WSL 2 (wsl --set-version <distro> 2)"
60
+ fi
61
+ fi
62
+ HUB_TARGET="$hub_os-$hub_arch"
63
+ }
64
+
65
+ # hub_fetch <url> <file>: https stays https across redirects.
66
+ hub_fetch() {
67
+ case "$1" in
68
+ https://*) hub_tls=1 ;;
69
+ *) hub_tls=0 ;;
70
+ esac
71
+ if hub_has curl; then
72
+ if [ "$hub_tls" = 1 ]; then
73
+ curl -fsSL --proto '=https' --proto-redir '=https' -o "$2" "$1" </dev/null
74
+ else
75
+ curl -fsSL -o "$2" "$1" </dev/null
76
+ fi
77
+ elif hub_has wget; then
78
+ if [ "$hub_tls" = 1 ]; then
79
+ wget -q --https-only -O "$2" "$1" </dev/null
80
+ else
81
+ wget -q -O "$2" "$1" </dev/null
82
+ fi
83
+ else
84
+ hub_die 1 "needs curl or wget to download hubctl"
85
+ fi
86
+ }
87
+
88
+ # hub_sha256 <file>: the lower-case hex digest on stdout.
89
+ hub_sha256() {
90
+ if hub_has sha256sum; then
91
+ sha256sum "$1" </dev/null | awk '{print tolower($1)}'
92
+ elif hub_has shasum; then
93
+ shasum -a 256 "$1" </dev/null | awk '{print tolower($1)}'
94
+ else
95
+ hub_die 1 "needs sha256sum or shasum to check the hubctl download; nothing was run"
96
+ fi
97
+ }
98
+
99
+ # hub_json_field <name> <file>: the first string value of "name" (hubctl's --json output is one field per line).
100
+ hub_json_field() {
101
+ sed -n "s/^[[:space:]]*\"$1\":[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$2" | head -n 1
102
+ }
103
+
104
+ hub_on_path() {
105
+ case ":$PATH:" in
106
+ *":$1:"* | *":$1/:"*) return 0 ;;
107
+ esac
108
+ return 1
109
+ }
110
+
111
+ # hub_run <command...>: runs hubctl with stdin from /dev/null; its exit code in hub_rc (set -e never trips).
112
+ hub_run() {
113
+ if "$hub_bin" "$@" </dev/null; then
114
+ hub_rc=0
115
+ else
116
+ hub_rc=$?
117
+ fi
118
+ }
119
+
120
+ main() {
121
+ set -eu
122
+ umask 077
123
+
124
+ HUB_URL='{{HUB_URL}}'
125
+ HUB_VERSION='{{HUB_VERSION}}'
126
+ RELEASE_BASE='{{RELEASE_BASE}}'
127
+ PROFILE='{{PROFILE}}'
128
+ # A leading ~/ is kept literal here and expanded to $HOME before preview.
129
+ # shellcheck disable=SC2088
130
+ WORKSPACE_ROOT='{{WORKSPACE_ROOT}}'
131
+ ACTIVATE='{{ACTIVATE}}'
132
+ INSECURE_HTTP='{{INSECURE_HTTP}}'
133
+
134
+ hub_release=${HUB_INSTALL_RELEASE_BASE:-$RELEASE_BASE}
135
+ hub_release=${hub_release%/}
136
+
137
+ hub_say "setting up this machine for $HUB_URL (hub $HUB_VERSION, profile $PROFILE)"
138
+ if [ "$INSECURE_HTTP" = 1 ]; then
139
+ hub_say "warning: $HUB_URL is plain http: this machine's token travels unencrypted on your network"
140
+ fi
141
+
142
+ hub_target
143
+
144
+ HUB_TMP=$(mktemp -d 2>/dev/null || mktemp -d -t hub-setup)
145
+ if [ -z "$HUB_TMP" ] || [ ! -d "$HUB_TMP" ]; then
146
+ hub_die 1 "cannot create a private temporary directory"
147
+ fi
148
+ trap 'rm -rf "$HUB_TMP"' EXIT
149
+ trap 'exit 130' INT
150
+ trap 'exit 143' TERM HUP
151
+
152
+ # ---------- download and check hubctl ----------
153
+ hub_asset="hubctl-$HUB_TARGET"
154
+ hub_bin="$HUB_TMP/hubctl"
155
+ hub_manifest="$HUB_TMP/hubctl-manifest.json"
156
+ hub_say "downloading $hub_asset from $hub_release"
157
+ hub_fetch "$hub_release/HUBCTL_SHA256SUMS" "$HUB_TMP/HUBCTL_SHA256SUMS" || hub_die 1 "could not download $hub_release/HUBCTL_SHA256SUMS"
158
+ hub_fetch "$hub_release/$hub_asset" "$hub_bin" || hub_die 1 "could not download $hub_release/$hub_asset"
159
+ hub_fetch "$hub_release/hubctl-manifest.json" "$hub_manifest" || hub_die 1 "could not download $hub_release/hubctl-manifest.json"
160
+
161
+ # "<sha256> <file>" lines (sha256sum or shasum; a leading * marks binary mode); exactly this file's name.
162
+ hub_expected=$(awk -v f="$hub_asset" '{ n = $2; sub(/^\*/, "", n) } NF == 2 && n == f { print tolower($1); exit }' "$HUB_TMP/HUBCTL_SHA256SUMS")
163
+ case "$hub_expected" in
164
+ "") hub_die 11 "HUBCTL_SHA256SUMS has no checksum for $hub_asset; nothing was run" ;;
165
+ *[!0-9a-f]*) hub_die 11 "HUBCTL_SHA256SUMS has a malformed checksum for $hub_asset; nothing was run" ;;
166
+ esac
167
+ if [ "${#hub_expected}" -ne 64 ]; then
168
+ hub_die 11 "HUBCTL_SHA256SUMS has a malformed checksum for $hub_asset; nothing was run"
169
+ fi
170
+ hub_actual=$(hub_sha256 "$hub_bin")
171
+ if [ "$hub_actual" != "$hub_expected" ]; then
172
+ hub_die 11 "the sha256 of $hub_asset does not match HUBCTL_SHA256SUMS (got $hub_actual); nothing was run"
173
+ fi
174
+ chmod 700 "$hub_bin"
175
+ hub_say "checksum ok (hubctl checks the release manifest signature itself)"
176
+
177
+ # ---------- enrollment ----------
178
+ # Skipped only when hubctl reports this exact hub; otherwise `machine enroll` decides (it is a no-op for the
179
+ # same hub and refuses another hub without --reenroll). A credential this hub revoked is replaced.
180
+ hub_enroll=1
181
+ hub_reenroll=0
182
+ hub_run machine status --json >"$HUB_TMP/status.json" 2>"$HUB_TMP/status.err"
183
+ hub_state=$(hub_json_field state "$HUB_TMP/status.json")
184
+ hub_server=$(hub_json_field server "$HUB_TMP/status.json")
185
+ if [ "$hub_server" = "$HUB_URL" ]; then
186
+ case "$hub_rc:$hub_state" in
187
+ 0:verified | 0:configured | 0:insecure)
188
+ hub_enroll=0
189
+ hub_say "this machine is already enrolled with $HUB_URL ($hub_state); not enrolling again"
190
+ ;;
191
+ 9:revoked)
192
+ hub_reenroll=1
193
+ hub_say "$HUB_URL revoked this machine's credential; enrolling it again"
194
+ ;;
195
+ esac
196
+ fi
197
+ if [ "$hub_enroll" = 1 ]; then
198
+ set -- machine enroll --server "$HUB_URL"
199
+ if [ "$INSECURE_HTTP" = 1 ]; then
200
+ set -- "$@" --allow-insecure-http
201
+ fi
202
+ if [ "$hub_reenroll" = 1 ]; then
203
+ set -- "$@" --reenroll
204
+ fi
205
+ hub_say "enrolling: approve the code below in the hub console"
206
+ hub_run "$@"
207
+ case "$hub_rc" in
208
+ 0) ;;
209
+ 5) hub_die 5 "the enrollment was denied on the hub; nothing was set up. Run the command again to start over" ;;
210
+ 4) hub_die 4 "the enrollment did not complete (expired, cancelled, or this machine is enrolled with another hub: see above); nothing was set up" ;;
211
+ *) hub_die "$hub_rc" "enrollment failed (hubctl exit $hub_rc); nothing was set up" ;;
212
+ esac
213
+ fi
214
+
215
+ # ---------- preview and apply ----------
216
+ case "$WORKSPACE_ROOT" in
217
+ \~) WORKSPACE_ROOT=$HOME ;;
218
+ \~/*) WORKSPACE_ROOT="$HOME/${WORKSPACE_ROOT#\~/}" ;;
219
+ esac
220
+ hub_preview="$HUB_TMP/preview.json"
221
+ set -- setup preview --profile "$PROFILE" --enrollment separate --manifest "$hub_manifest" --out "$hub_preview"
222
+ if [ -n "$WORKSPACE_ROOT" ]; then
223
+ set -- "$@" --workspace-root "$WORKSPACE_ROOT"
224
+ fi
225
+ if [ "$ACTIVATE" = 1 ]; then
226
+ set -- "$@" --confirm-activation
227
+ fi
228
+ hub_run "$@"
229
+ if [ "$hub_rc" != 0 ]; then
230
+ hub_die "$hub_rc" "setup preview failed (hubctl exit $hub_rc); nothing was changed"
231
+ fi
232
+ if [ ! -s "$hub_preview" ]; then
233
+ hub_die 1 "setup preview wrote no preview; nothing was changed"
234
+ fi
235
+
236
+ # Manual edits (a config behind a symlink, a format hubctl cannot edit losslessly) are blockers, and apply
237
+ # refuses the whole plan while any remains (contracts §3.12, exit 10). The preview above printed them.
238
+ if grep -Eq '"blockers":[[:space:]]*\[[[:space:]]*$' "$hub_preview"; then
239
+ hub_die 10 "nothing was applied: make the edits listed above by hand (hubctl does not write those files), then run this command again"
240
+ fi
241
+
242
+ set -- setup apply --preview "$hub_preview" --manifest "$hub_manifest"
243
+ if [ "$ACTIVATE" = 1 ]; then
244
+ set -- "$@" --confirm-activation
245
+ fi
246
+ hub_run "$@"
247
+ case "$hub_rc" in
248
+ 0) ;;
249
+ 10) hub_die 10 "setup apply needs edits by hand or found a conflict (see above); resolve them, then run this command again" ;;
250
+ *) hub_die "$hub_rc" "setup apply failed (hubctl exit $hub_rc); see above" ;;
251
+ esac
252
+
253
+ # ---------- verify ----------
254
+ hub_run setup verify --profile "$PROFILE"
255
+ if ! hub_on_path "$HOME/.local/bin"; then
256
+ hub_say "note: $HOME/.local/bin is not on your PATH; add it (for example in ~/.profile) to run hub and hubctl by name"
257
+ fi
258
+ case "$hub_rc" in
259
+ 0) hub_say "done: this machine is set up for $HUB_URL" ;;
260
+ 15) hub_say "installed, not ready yet: finish the next steps above, then check with: hubctl setup verify" ;;
261
+ *) hub_die "$hub_rc" "setup verify failed (hubctl exit $hub_rc)" ;;
262
+ esac
263
+ return 0
264
+ }
265
+
266
+ main "$@"
@@ -0,0 +1,146 @@
1
+ // GET /install.sh and /install.ps1 (CONTRACT.md): the query allowlist, the values put into a template, and the
2
+ // rendering. Pure: the route in http.ts passes the hub's public URL, version and HUB_RELEASE_BASE.
3
+ //
4
+ // Every value is checked against UNSAFE before it reaches a template: the templates put them inside single quotes,
5
+ // so none may contain ' " ` $ \ or a line break. A value that does is a server bug and throws (the route answers 500),
6
+ // never a script.
7
+ import { checkServerURL, isLoopbackServer, isPrivateNetworkHost } from "../../../packages/cli/src/bootstrap/enrollment/policy.ts"
8
+ import { INSTALL_TEMPLATES } from "./templates"
9
+
10
+ export type InstallScript = "sh" | "ps1"
11
+ export type InstallProfile = "session-only" | "launch-node" | "full-harness"
12
+
13
+ export interface InstallChoices {
14
+ readonly profile: InstallProfile
15
+ /** The workspace root for launch-node, else "". */
16
+ readonly root: string
17
+ readonly activate: "0" | "1"
18
+ }
19
+
20
+ export const PLACEHOLDERS = ["HUB_URL", "HUB_VERSION", "RELEASE_BASE", "PROFILE", "WORKSPACE_ROOT", "ACTIVATE", "INSECURE_HTTP"] as const
21
+ export type Placeholder = (typeof PLACEHOLDERS)[number]
22
+
23
+ const PROFILES: readonly InstallProfile[] = ["session-only", "launch-node", "full-harness"]
24
+ const KEYS = new Set(["profile", "root", "activate"])
25
+ const DEFAULT_ROOT = "~/projects"
26
+ const ROOT = /^(?:\/|~\/)[A-Za-z0-9._/~-]*$/
27
+ const ROOT_MAX = 200
28
+ const SEMVER = /^\d+\.\d+\.\d+$/
29
+ const UNSAFE = /['"`$\\\r\n]/
30
+ const RELEASES = "https://github.com/huynle/hub-releases/releases/download"
31
+
32
+ type Parsed = { readonly ok: true; readonly choices: InstallChoices } | { readonly ok: false; readonly reason: string }
33
+
34
+ const refuse = (reason: string): Parsed => ({ ok: false, reason })
35
+
36
+ function rootProblem(root: string): string | undefined {
37
+ if (root.length < 1 || root.length > ROOT_MAX) return `root must be 1-${ROOT_MAX} characters`
38
+ if (!ROOT.test(root)) return "root must start with / or ~/ and use only letters, digits and . _ / ~ -"
39
+ if (root.includes("//")) return "root must not contain //"
40
+ if (root.split("/").includes("..")) return "root must not contain a .. segment"
41
+ return undefined
42
+ }
43
+
44
+ /** The choices of an install query, or why it is refused (a one-line reason for the 400). */
45
+ export function parseInstallQuery(script: InstallScript, search: URLSearchParams): Parsed {
46
+ const seen = new Map<string, string>()
47
+ for (const [key, value] of search) {
48
+ if (!KEYS.has(key)) return refuse(`unknown query parameter ${JSON.stringify(key.slice(0, 40))} (allowed: profile, root, activate)`)
49
+ if (seen.has(key)) return refuse(`query parameter ${key} given more than once`)
50
+ seen.set(key, value)
51
+ }
52
+ const profile = seen.get("profile") ?? "session-only"
53
+ if (!PROFILES.includes(profile as InstallProfile)) return refuse(`profile must be one of ${PROFILES.join(", ")}`)
54
+ const activate = seen.get("activate") ?? "0"
55
+ if (activate !== "0" && activate !== "1") return refuse("activate must be 0 or 1")
56
+ const root = seen.get("root")
57
+ if (script === "ps1") {
58
+ if (profile !== "session-only") return refuse("install.ps1 supports only profile=session-only for now")
59
+ if (root !== undefined) return refuse("install.ps1 takes no root (no launch-node on Windows yet)")
60
+ if (activate !== "0") return refuse("install.ps1 supports only activate=0 for now")
61
+ }
62
+ if (root !== undefined && profile !== "launch-node") return refuse("root is only for profile=launch-node")
63
+ if (root !== undefined) {
64
+ const problem = rootProblem(root)
65
+ if (problem) return refuse(problem)
66
+ }
67
+ return { ok: true, choices: { profile: profile as InstallProfile, root: profile === "launch-node" ? (root ?? DEFAULT_ROOT) : "", activate } }
68
+ }
69
+
70
+ /**
71
+ * HUB_RELEASE_BASE, normalized (no trailing slash), or undefined when not allowed: https, or plain http on loopback
72
+ * or a private-network IP address (the enrollment policy's rule, policy.ts); no user info, query, fragment or
73
+ * whitespace, and none of the characters a template cannot quote.
74
+ */
75
+ export function checkReleaseBase(value: string): string | undefined {
76
+ if (UNSAFE.test(value)) return undefined
77
+ const base = checkServerURL(value, { allowInsecureHTTP: true })
78
+ return base && !UNSAFE.test(base) ? base : undefined
79
+ }
80
+
81
+ export interface InstallContext {
82
+ /** The hub's public URL (config.publicURL). */
83
+ readonly publicURL: string
84
+ /** The hub version (as /api/me reports it). */
85
+ readonly version: string
86
+ /** HUB_RELEASE_BASE, already checked (checkReleaseBase). */
87
+ readonly releaseBase?: string
88
+ }
89
+
90
+ type Values = { readonly ok: true; readonly values: Record<Placeholder, string> } | { readonly ok: false; readonly status: 400 | 503; readonly reason: string }
91
+
92
+ /** The placeholder values for these choices, or why there is no script (400 plain-http hub, 503 no release). */
93
+ export function installValues(input: InstallContext & { readonly choices: InstallChoices }): Values {
94
+ const hub = checkServerURL(input.publicURL, { allowInsecureHTTP: true })
95
+ if (!hub) {
96
+ return { ok: false, status: 400, reason: "this hub's public URL must be https to install from it (plain http only on loopback or a private-network IP address)" }
97
+ }
98
+ const insecure = hub.startsWith("http:") && (isLoopbackServer(hub) || isPrivateNetworkHost(new URL(hub).hostname))
99
+ const release = input.releaseBase ?? (SEMVER.test(input.version) && input.version !== "0.0.0" ? `${RELEASES}/v${input.version}` : undefined)
100
+ if (!release) {
101
+ return { ok: false, status: 503, reason: `this hub (version ${input.version.slice(0, 40)}) has no published release to install; set HUB_RELEASE_BASE on the hub` }
102
+ }
103
+ const values: Record<Placeholder, string> = {
104
+ HUB_URL: hub,
105
+ HUB_VERSION: input.version,
106
+ RELEASE_BASE: release,
107
+ PROFILE: input.choices.profile,
108
+ WORKSPACE_ROOT: input.choices.root,
109
+ ACTIVATE: input.choices.activate,
110
+ INSECURE_HTTP: insecure ? "1" : "0",
111
+ }
112
+ assertSafe(values)
113
+ return { ok: true, values }
114
+ }
115
+
116
+ function assertSafe(values: Readonly<Record<string, string>>): void {
117
+ for (const [name, value] of Object.entries(values)) {
118
+ if (UNSAFE.test(value)) throw new Error(`install script value ${name} contains a character the template cannot quote`)
119
+ }
120
+ }
121
+
122
+ /** The template with every {{NAME}} replaced. Throws on an unsafe value, or a placeholder without a value or left over. */
123
+ export function renderTemplate(template: string, values: Readonly<Record<string, string>>): string {
124
+ assertSafe(values)
125
+ const out = template.replace(/\{\{([A-Z_]+)\}\}/g, (match, name: string) => {
126
+ const value = values[name]
127
+ if (value === undefined) throw new Error(`install template placeholder {{${name}}} has no value`)
128
+ return value
129
+ })
130
+ const left = /\{\{[^}\n]*\}\}/.exec(out)
131
+ if (left) throw new Error(`install template has a leftover placeholder ${left[0].slice(0, 60)}`)
132
+ return out
133
+ }
134
+
135
+ const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }
136
+
137
+ const text = (status: number, body: string): Response => new Response(body, { status, headers: TEXT_HEADERS })
138
+
139
+ /** The answer to GET /install.sh or /install.ps1: the rendered script, or a 400/503 one-line reason. */
140
+ export function installResponse(script: InstallScript, url: URL, ctx: InstallContext & { readonly template?: string }): Response {
141
+ const parsed = parseInstallQuery(script, url.searchParams)
142
+ if (!parsed.ok) return text(400, `${parsed.reason}\n`)
143
+ const values = installValues({ ...ctx, choices: parsed.choices })
144
+ if (!values.ok) return text(values.status, `${values.reason}\n`)
145
+ return text(200, renderTemplate(ctx.template ?? INSTALL_TEMPLATES[script], values.values))
146
+ }
@@ -0,0 +1,6 @@
1
+ // The install script templates (CONTRACT.md), imported as text so `bun build --compile` and the npm package carry
2
+ // them. The bodies belong to the scripts workstream; the server only fills their {{…}} placeholders (render.ts).
3
+ import sh from "./install.sh" with { type: "text" }
4
+ import ps1 from "./install.ps1" with { type: "text" }
5
+
6
+ export const INSTALL_TEMPLATES: { readonly sh: string; readonly ps1: string } = { sh, ps1 }
@@ -0,0 +1,9 @@
1
+ // Install script templates imported `with { type: "text" }` (templates.ts): Bun gives their contents as a string.
2
+ declare module "*.sh" {
3
+ const text: string
4
+ export default text
5
+ }
6
+ declare module "*.ps1" {
7
+ const text: string
8
+ export default text
9
+ }
@@ -12,7 +12,7 @@
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
14
  import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
- import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
15
+ import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
18
18
  import { SentenceStream } from "./say-stream"
@@ -187,6 +187,17 @@ interface PendingPermission {
187
187
  readonly tenant?: string
188
188
  readonly request?: PermissionRequest
189
189
  recommendation?: BlockerRecommendation
190
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
191
+ readonly blocker?: Blocker
192
+ }
193
+
194
+ /** A pending form and the processes that forwarded it. */
195
+ interface PendingForm {
196
+ connIDs: string[]
197
+ readonly sessionID: string
198
+ readonly tenant?: string
199
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
200
+ readonly blocker?: Blocker
190
201
  }
191
202
 
192
203
  /** Characters of a recommendation's author and reason on a blocker. */
@@ -399,6 +410,8 @@ interface WorkerSpeech {
399
410
 
400
411
  /** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
401
412
  const MEMORY = 1000
413
+ /** Pending permissions, and pending forms, kept per tenant (oldest dropped). */
414
+ export const PENDING_PER_TENANT = 200
402
415
 
403
416
  /** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
404
417
  function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
@@ -406,6 +419,12 @@ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void
406
419
  if (map.size > limit) map.delete(map.keys().next().value!)
407
420
  }
408
421
 
422
+ /** Adds an ID to a bounded set of IDs (oldest dropped). */
423
+ function rememberID(set: Set<string>, id: string, limit = 1000): void {
424
+ set.add(id)
425
+ if (set.size > limit) set.delete(set.values().next().value!)
426
+ }
427
+
409
428
  const eventSessionID = (event: ForwardedEvent): string | undefined => {
410
429
  const data = event.data as Record<string, any>
411
430
  const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
@@ -440,9 +459,11 @@ export class Registry {
440
459
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
441
460
  private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
442
461
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
443
- private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
444
- /** Form IDs already alerted (forms may be forwarded by several processes). */
462
+ private readonly forms = new Map<string, PendingForm>()
463
+ /** Form IDs already alerted (forms may be forwarded by several processes, and re-announced after a reconnect). */
445
464
  private readonly alertedForms = new Set<string>()
465
+ /** Permission IDs already alerted (a reconnecting spoke re-announces what is still pending). */
466
+ private readonly alertedPermissions = new Set<string>()
446
467
  /** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
447
468
  private readonly providerAlerted = new Set<string>()
448
469
  /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
@@ -719,6 +740,8 @@ export class Registry {
719
740
  const owner = this.sessionTenant(id)
720
741
  if (owner !== undefined && owner !== tenant) return
721
742
  }
743
+ // A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
744
+ if (oversizedBlockerID(event.type, data)) return
722
745
  if (sessionID) conn.activity.set(sessionID, this.tick())
723
746
  if (sessionID && event.type.startsWith("session.execution.")) {
724
747
  if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
@@ -733,34 +756,46 @@ export class Registry {
733
756
  if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
734
757
  const known = this.permissions.get(data.id)
735
758
  if (known) {
736
- // The same request forwarded by another process: one more place to answer it.
759
+ // The same request forwarded by another process (or re-announced): one more place to answer it, and its
760
+ // blocker back on a status board that lost it.
737
761
  if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
762
+ this.observeBoards(event, tenant)
738
763
  return
739
764
  }
740
765
  const request = permissionRequest(data)
741
- this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}) })
742
- this.alert("permission", sessionID, { requestID: data.id })
766
+ const blocker = askBlocker(event)
767
+ this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
768
+ this.capPending(this.permissions, tenant, (id) => this.dropPermission(id))
769
+ if (!this.alertedPermissions.has(data.id) && this.alert("permission", sessionID, { requestID: data.id })) rememberID(this.alertedPermissions, data.id)
743
770
  }
744
771
  if (event.type === "form.created" && sessionID) {
745
772
  const formID = (data.form as Record<string, unknown> | undefined)?.id
746
773
  if (typeof formID === "string") {
747
774
  const known = this.forms.get(formID)
748
775
  if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
749
- else if (!known) remember(this.forms, formID, { connIDs: [connID], sessionID })
750
- }
751
- if (typeof formID === "string" && !this.alertedForms.has(formID)) {
752
- this.alertedForms.add(formID)
753
- if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
754
- this.alert("question", sessionID)
776
+ else if (!known) {
777
+ const blocker = askBlocker(event)
778
+ this.forms.set(formID, { connIDs: [connID], sessionID, tenant, ...(blocker ? { blocker } : {}) })
779
+ this.capPending(this.forms, tenant, (id) => this.dropForm(id))
780
+ }
755
781
  }
782
+ if (typeof formID === "string" && !this.alertedForms.has(formID) && this.alert("question", sessionID)) rememberID(this.alertedForms, formID)
756
783
  }
757
784
  let closedPermission: [string, PendingPermission] | undefined
758
- if (event.type === "permission.replied" && typeof data.requestID === "string") {
759
- const origin = this.permissions.get(data.requestID)
760
- if (origin && this.permissions.delete(data.requestID)) closedPermission = [data.requestID, origin]
761
- }
762
- if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
763
- this.forms.delete(data.id)
785
+ const permissionClose = event.type === "permission.replied" && typeof data.requestID === "string"
786
+ const formClose = (event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string"
787
+ if (permissionClose || formClose) {
788
+ const id = (permissionClose ? data.requestID : data.id) as string
789
+ const map: Map<string, PendingPermission | PendingForm> = permissionClose ? this.permissions : this.forms
790
+ const origin = map.get(id)
791
+ // Only the tenant that asked closes it (IDs are not secret enough to let another tenant end it).
792
+ const own = origin !== undefined && (origin.tenant === undefined || origin.tenant === tenant)
793
+ if (own && data.unlisted === true) {
794
+ // A spoke found it no longer listed by its own harness: only that process stops holding it.
795
+ origin.connIDs = origin.connIDs.filter((c) => c !== connID)
796
+ if (origin.connIDs.length) return
797
+ }
798
+ if (own && map.delete(id) && permissionClose) closedPermission = [id, origin as PendingPermission]
764
799
  }
765
800
 
766
801
  if (sessionID) this.trackRun(event.type, sessionID)
@@ -1951,6 +1986,33 @@ export class Registry {
1951
1986
  if (next.length === pending.length) break
1952
1987
  pending = next
1953
1988
  }
1989
+ this.restoreBlockers()
1990
+ }
1991
+
1992
+ /** Keeps at most PENDING_PER_TENANT entries of the tenant in a pending map: the oldest are dropped (with their blocker). */
1993
+ private capPending(map: Map<string, PendingPermission | PendingForm>, tenant: string, drop: (id: string) => void): void {
1994
+ const own = [...map].filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
1995
+ for (const [id] of own.slice(0, Math.max(0, own.length - PENDING_PER_TENANT))) drop(id)
1996
+ }
1997
+
1998
+ /**
1999
+ * Puts pending permissions and forms back on the status boards that lost them: a board recreated after its
2000
+ * session left the reported sessions for a moment, or one created after the question was asked (events reach
2001
+ * only existing boards). OpenCode still waits for them; only a reply, a cancel or their processes going away
2002
+ * end them (production 2026-11-05: a question card vanished while the session still waited).
2003
+ */
2004
+ private restoreBlockers(): void {
2005
+ const restored = new Set<string>()
2006
+ const restore = ({ sessionID, tenant, blocker }: PendingPermission | PendingForm) => {
2007
+ if (!blocker) return
2008
+ const rootID = this.rootOf(sessionID)
2009
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
2010
+ if (!rootID || !board || (tenant !== undefined && this.sessionTenant(rootID) !== tenant)) return
2011
+ if (board.restoreBlocker(sessionID, blocker)) restored.add(rootID)
2012
+ }
2013
+ for (const origin of this.permissions.values()) restore(origin)
2014
+ for (const origin of this.forms.values()) restore(origin)
2015
+ for (const rootID of restored) this.emit({ type: "status", sessionID: rootID, sessions: this.live(this.boards.get(rootID)!.snapshot().sessions) })
1954
2016
  }
1955
2017
 
1956
2018
  // ---------- cluster (design §9) ----------
@@ -1978,7 +2040,7 @@ export class Registry {
1978
2040
  const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
1979
2041
  for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
1980
2042
  for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1981
- for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
2043
+ for (const origin of this.forms.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1982
2044
  for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
1983
2045
  for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
1984
2046
  return [...tenants]
@@ -2113,11 +2175,12 @@ export class Registry {
2113
2175
  * Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
2114
2176
  * session some device shows (spoken replies check that themselves: the watching device speaks them).
2115
2177
  */
2116
- private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
2178
+ /** True when it was raised (or skipped because a device shows the session); false for a session on no board. */
2179
+ private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): boolean {
2117
2180
  const entry = [...this.boards].find(([, board]) => board.get(sessionID))
2118
- if (!entry) return
2181
+ if (!entry) return false
2119
2182
  const [rootID, board] = entry
2120
- if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
2183
+ if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return true
2121
2184
  const alert: Alert = {
2122
2185
  kind,
2123
2186
  sessionID: rootID,
@@ -2130,6 +2193,7 @@ export class Registry {
2130
2193
  }
2131
2194
  const owner = this.sessionTenant(rootID)
2132
2195
  for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
2196
+ return true
2133
2197
  }
2134
2198
 
2135
2199
  private emit(message: RegistryMessage): void {
@@ -2137,3 +2201,10 @@ export class Registry {
2137
2201
  for (const listener of this.listeners) listener(message, tenant)
2138
2202
  }
2139
2203
  }
2204
+
2205
+ /** A permission.asked / form.created (or their closes) with an ID longer than any card keeps. */
2206
+ function oversizedBlockerID(type: string, data: Record<string, unknown>): boolean {
2207
+ const ids =
2208
+ type === "permission.asked" ? [data.id] : type === "form.created" ? [(data.form as Record<string, unknown> | undefined)?.id] : []
2209
+ return ids.some((id) => typeof id === "string" && id.length > BLOCKER_ID_MAX)
2210
+ }