@huynle/hub 0.6.0 → 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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,155 @@
1
+ // Compiles a hub profile into an OpenShell sandbox policy (decision lk6gn3u7 §7; OpenShell 0.1.x policy schema
2
+ // version 1). Deny by default: the policy holds only these network rules, each for the harness binary alone
3
+ // (OpenShell matches a connection's ancestors too, so the agent's shells and git inherit the harness's egress):
4
+ //
5
+ // hub the hub, protocol tcp: the supervisor proxy relays it without terminating TLS. A loopback hub is
6
+ // reached through host.openshell.internal (OpenShell never authorizes loopback destinations).
7
+ // git_remote the launch's git remote, only when the profile sets allowGitRemote (https inspected; ssh tcp).
8
+ // profile_egress the profile's extra endpoints, minus any host:port an earlier rule already has.
9
+ //
10
+ // Model provider endpoints are not listed: attached OpenShell providers contribute their own `_provider_*` rules,
11
+ // and the keys stay in OpenShell's proxy. The filesystem section is OpenShell's documented baseline plus the
12
+ // sandbox working directory and /sandbox.
13
+ //
14
+ // Every value is validated before it gets here and every string is emitted double-quoted (JSON strings are YAML
15
+ // scalars), so no input can add keys to the document.
16
+ import { isGitRemote } from "../../protocol/src/index.ts"
17
+ import { RuntimeError } from "../../runtime-sdk/src/index.ts"
18
+ import { egressHost, type EgressEndpoint, type OpenShellProfile } from "./profiles.ts"
19
+
20
+ /** The only binary the policy lets out: the harness baked into the sandbox image. */
21
+ export const HARNESS_BINARY = "/usr/local/bin/opencode"
22
+ /** How a sandbox reaches the gateway host (and so a loopback-bound hub). */
23
+ export const SANDBOX_HOST_ALIAS = "host.openshell.internal"
24
+
25
+ const READ_ONLY = ["/usr", "/lib", "/bin", "/etc", "/app", "/var/log", "/proc", "/dev/urandom"]
26
+ const READ_WRITE = ["/sandbox", "/tmp", "/dev/null"]
27
+ const DEFAULT_PORTS: Record<string, number> = { "http:": 80, "ws:": 80, "https:": 443, "wss:": 443 }
28
+
29
+ /** A destination in a network rule; `tcp` relays the raw connection (no TLS termination, no request checks). */
30
+ export interface PolicyEndpoint extends EgressEndpoint {
31
+ readonly protocol?: "tcp"
32
+ }
33
+
34
+ /** The hub as the sandbox sees it: the policy endpoint and the URL the harness inside connects to. */
35
+ export interface HubEndpoint {
36
+ readonly host: string
37
+ readonly port: number
38
+ readonly url: string
39
+ }
40
+
41
+ export interface PolicyInput {
42
+ /** The hub URL as the node knows it (config.json `server`). */
43
+ readonly hubURL: string
44
+ readonly profile: OpenShellProfile
45
+ /** The launch's workspace remote; allowed only when the profile sets allowGitRemote. */
46
+ readonly gitRemote?: string
47
+ }
48
+
49
+ export interface CompiledPolicy {
50
+ /** The policy document for `openshell sandbox create --policy`. */
51
+ readonly yaml: string
52
+ /** OpenShell providers to attach (`--provider`), whose rules and credentials OpenShell adds itself. */
53
+ readonly providers: readonly string[]
54
+ /** The hub URL to hand the harness inside the sandbox. */
55
+ readonly hubURL: string
56
+ }
57
+
58
+ const isLoopback = (host: string): boolean =>
59
+ host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
60
+
61
+ /** Where the sandbox reaches the hub; RuntimeError when the URL is not one a policy can allow. */
62
+ export function sandboxHubEndpoint(hubURL: string): HubEndpoint {
63
+ let url: URL
64
+ try {
65
+ url = new URL(hubURL)
66
+ } catch {
67
+ throw new RuntimeError("the hub URL is not an http(s) or ws(s) URL")
68
+ }
69
+ const defaultPort = DEFAULT_PORTS[url.protocol]
70
+ if (defaultPort === undefined || !url.hostname) throw new RuntimeError("the hub URL is not an http(s) or ws(s) URL")
71
+ const port = url.port ? Number(url.port) : defaultPort
72
+ if (isLoopback(url.hostname)) {
73
+ url.hostname = SANDBOX_HOST_ALIAS
74
+ return { host: SANDBOX_HOST_ALIAS, port, url: url.href }
75
+ }
76
+ const host = url.hostname.includes("*") ? undefined : egressHost(url.hostname)
77
+ if (!host) throw new RuntimeError(`the hub's host ${url.hostname.replace(/^\[|\]$/g, "")} cannot be allowed in a sandbox policy`)
78
+ return { host, port, url: url.href }
79
+ }
80
+
81
+ /** The endpoint a git remote needs: https (443) inspected, ssh or scp-style (22) tcp; undefined when unusable. */
82
+ export function gitRemoteEndpoint(remote: string): PolicyEndpoint | undefined {
83
+ if (!isGitRemote(remote)) return undefined
84
+ let host: string | undefined
85
+ let port: number
86
+ let tcp: boolean
87
+ const scp = /^[^@/]+@([^:/]+):/.exec(remote)
88
+ if (scp?.[1]) {
89
+ host = scp[1]
90
+ port = 22
91
+ tcp = true
92
+ } else {
93
+ const url = new URL(remote)
94
+ host = url.hostname
95
+ tcp = url.protocol === "ssh:"
96
+ port = url.port ? Number(url.port) : tcp ? 22 : 443
97
+ }
98
+ const clean = egressHost(host)
99
+ if (!clean || clean.includes("*") || isLoopback(clean)) return undefined
100
+ return tcp ? { host: clean, port, protocol: "tcp" } : { host: clean, port }
101
+ }
102
+
103
+ interface Rule {
104
+ readonly key: string
105
+ readonly name: string
106
+ readonly endpoints: readonly PolicyEndpoint[]
107
+ }
108
+
109
+ const q = (s: string) => JSON.stringify(s)
110
+
111
+ function emit(profile: string, rules: readonly Rule[]): string {
112
+ const lines = [
113
+ `# hub sandbox policy for profile ${profile}: deny by default; only the rules below allow egress.`,
114
+ "version: 1",
115
+ "",
116
+ "filesystem_policy:",
117
+ " include_workdir: true",
118
+ " read_only:",
119
+ ...READ_ONLY.map((p) => ` - ${q(p)}`),
120
+ " read_write:",
121
+ ...READ_WRITE.map((p) => ` - ${q(p)}`),
122
+ "",
123
+ "landlock:",
124
+ " compatibility: best_effort",
125
+ "",
126
+ "network_policies:",
127
+ ]
128
+ for (const rule of rules) {
129
+ lines.push(` ${rule.key}:`, ` name: ${q(rule.name)}`, " endpoints:")
130
+ for (const e of rule.endpoints) {
131
+ lines.push(` - host: ${q(e.host)}`, ` port: ${e.port}`)
132
+ if (e.protocol) lines.push(` protocol: ${e.protocol}`)
133
+ }
134
+ lines.push(" binaries:", ` - path: ${q(HARNESS_BINARY)}`)
135
+ }
136
+ return lines.join("\n") + "\n"
137
+ }
138
+
139
+ /** The OpenShell policy, providers and in-sandbox hub URL for a launch with this profile. */
140
+ export function compilePolicy(input: PolicyInput): CompiledPolicy {
141
+ const hub = sandboxHubEndpoint(input.hubURL)
142
+ const rules: Rule[] = [{ key: "hub", name: "hub", endpoints: [{ host: hub.host, port: hub.port, protocol: "tcp" }] }]
143
+
144
+ if (input.profile.allowGitRemote && input.gitRemote !== undefined) {
145
+ const git = gitRemoteEndpoint(input.gitRemote)
146
+ if (!git) throw new RuntimeError("the launch's git remote has no host the sandbox can reach")
147
+ rules.push({ key: "git_remote", name: "git-remote", endpoints: [git] })
148
+ }
149
+
150
+ const taken = new Set(rules.flatMap((r) => r.endpoints.map((e) => `${e.host}:${e.port}`)))
151
+ const extras = input.profile.endpoints.filter((e) => !taken.has(`${e.host}:${e.port}`))
152
+ if (extras.length > 0) rules.push({ key: "profile_egress", name: "profile-egress", endpoints: extras.map((e) => ({ host: e.host, port: e.port })) })
153
+
154
+ return { yaml: emit(input.profile.name, rules), providers: [...input.profile.providers], hubURL: hub.url }
155
+ }
@@ -0,0 +1,181 @@
1
+ // Hub profiles for OpenShell sandboxes (decision lk6gn3u7 §2, §4, §9): node.json `openshell.profiles`, a map of
2
+ // profile name to { image?, providers?, endpoints?, allowGitRemote? }. Phase 5 keeps them on the node; a hub-side
3
+ // profile store comes later. They are not OpenShell's own (credential) profiles: `providers` names OpenShell
4
+ // providers the node operator created, and the sandbox only ever sees their placeholders.
5
+ //
6
+ // Normalized field by field like the rest of node.json: a bad field is dropped with one problem line. A profile
7
+ // that asks for what the vm backend cannot honour (host mounts, cpu, memory) is never used in a weaker form: it is
8
+ // listed as unsupported and a launch naming it fails `unsupported`.
9
+ //
10
+ // node.json: { "openshell": { "profiles": { "untrusted-code": { "image": "hub-sandbox:0.1",
11
+ // "providers": ["anthropic"], "endpoints": ["registry.npmjs.org:443"], "allowGitRemote": false } } } }
12
+ import { token } from "../../protocol/src/wire.ts"
13
+ import { RuntimeError } from "../../runtime-sdk/src/index.ts"
14
+
15
+ /** The profile a launch without `runtime.profile` gets; built in (deny all but the hub) unless node.json sets it. */
16
+ export const DEFAULT_PROFILE = "default"
17
+ export const PROFILES_MAX = 32
18
+ export const ENDPOINTS_MAX = 64
19
+ const NAME_MAX = 64
20
+ const IMAGE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/
21
+ const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
22
+ const KNOWN_FIELDS = new Set(["image", "providers", "endpoints", "allowGitRemote"])
23
+ /** Fields the vm backend cannot honour (decision 9), in the order the reason lists them. */
24
+ const UNSUPPORTED_FIELDS = ["mounts", "cpu", "memoryMB", "memory"] as const
25
+
26
+ /** An egress destination a profile adds to the policy. */
27
+ export interface EgressEndpoint {
28
+ readonly host: string
29
+ readonly port: number
30
+ }
31
+
32
+ export interface OpenShellProfile {
33
+ readonly name: string
34
+ /** The sandbox image; unset: the runtime's default image. */
35
+ readonly image?: string
36
+ /** OpenShell provider names attached to the sandbox (model keys, injected by OpenShell's proxy). */
37
+ readonly providers: readonly string[]
38
+ /** Extra egress beyond the hub (and the providers' own endpoints). */
39
+ readonly endpoints: readonly EgressEndpoint[]
40
+ /** Allow egress to the launch's git remote (pushing from inside is a later opt-in; off by default). */
41
+ readonly allowGitRemote: boolean
42
+ }
43
+
44
+ export interface OpenShellProfiles {
45
+ readonly profiles: Readonly<Record<string, OpenShellProfile>>
46
+ /** Profile name → why it cannot be used on this node. */
47
+ readonly unsupported: Readonly<Record<string, string>>
48
+ readonly problems: readonly string[]
49
+ }
50
+
51
+ /** A launch named a profile this node does not have (`not_found`) or cannot honour (`unsupported`). */
52
+ export class ProfileError extends RuntimeError {
53
+ constructor(
54
+ readonly code: "unsupported" | "not_found",
55
+ message: string,
56
+ ) {
57
+ super(message)
58
+ }
59
+ }
60
+
61
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
62
+ const show = (v: unknown) => JSON.stringify(v)?.slice(0, 80) ?? String(v)
63
+ const builtinDefault = (): OpenShellProfile => ({ name: DEFAULT_PROFILE, providers: [], endpoints: [], allowGitRemote: false })
64
+
65
+ /** A hostname (lowercase), or a wildcard host OpenShell accepts: `*` or `**` as the first of at least three labels. */
66
+ export function egressHost(v: string): string | undefined {
67
+ const host = v.toLowerCase()
68
+ if (host.length > 253) return undefined
69
+ const labels = host.split(".")
70
+ const [first, ...rest] = labels
71
+ const wildcard = first === "*" || first === "**"
72
+ if (wildcard && labels.length < 3) return undefined
73
+ return (wildcard ? rest : labels).every((label) => LABEL.test(label)) ? host : undefined
74
+ }
75
+
76
+ const isPort = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535
77
+
78
+ /** "host:port" or { host, port }. */
79
+ function egressEndpoint(v: unknown): EgressEndpoint | undefined {
80
+ let host: unknown
81
+ let port: unknown
82
+ if (typeof v === "string") {
83
+ const colon = v.lastIndexOf(":")
84
+ if (colon < 0 || !/^\d{1,5}$/.test(v.slice(colon + 1))) return undefined
85
+ host = v.slice(0, colon)
86
+ port = Number(v.slice(colon + 1))
87
+ } else if (isObject(v)) {
88
+ host = v.host
89
+ port = v.port
90
+ }
91
+ if (typeof host !== "string" || !isPort(port)) return undefined
92
+ const clean = egressHost(host)
93
+ return clean ? { host: clean, port } : undefined
94
+ }
95
+
96
+ function normalizeProfile(name: string, r: Record<string, unknown>, problems: string[]): OpenShellProfile {
97
+ const at = `openshell.profiles.${name}`
98
+ for (const key of Object.keys(r)) if (!KNOWN_FIELDS.has(key)) problems.push(`${at}: unknown field ${show(key)} ignored`)
99
+
100
+ let image: string | undefined
101
+ if (r.image !== undefined) {
102
+ if (typeof r.image === "string" && IMAGE.test(r.image)) image = r.image
103
+ else problems.push(`${at}.image: ${show(r.image)} is not an image reference`)
104
+ }
105
+
106
+ const providers: string[] = []
107
+ if (r.providers !== undefined && !Array.isArray(r.providers)) problems.push(`${at}.providers must be a list of provider names`)
108
+ for (const provider of Array.isArray(r.providers) ? r.providers : []) {
109
+ if (!token(provider, NAME_MAX)) problems.push(`${at}.providers: ${show(provider)} is not a provider name`)
110
+ else if (!providers.includes(provider)) providers.push(provider)
111
+ }
112
+
113
+ const endpoints: EgressEndpoint[] = []
114
+ if (r.endpoints !== undefined && !Array.isArray(r.endpoints)) problems.push(`${at}.endpoints must be a list of host:port`)
115
+ for (const raw of Array.isArray(r.endpoints) ? r.endpoints : []) {
116
+ const endpoint = egressEndpoint(raw)
117
+ if (!endpoint) problems.push(`${at}.endpoints: ${show(raw)} is not host:port`)
118
+ else if (!endpoints.some((e) => e.host === endpoint.host && e.port === endpoint.port)) endpoints.push(endpoint)
119
+ }
120
+ if (endpoints.length > ENDPOINTS_MAX) {
121
+ problems.push(`${at}.endpoints: at most ${ENDPOINTS_MAX} endpoints; the rest are ignored`)
122
+ endpoints.length = ENDPOINTS_MAX
123
+ }
124
+
125
+ let allowGitRemote = false
126
+ if (r.allowGitRemote !== undefined) {
127
+ if (typeof r.allowGitRemote === "boolean") allowGitRemote = r.allowGitRemote
128
+ else problems.push(`${at}.allowGitRemote must be true or false`)
129
+ }
130
+
131
+ return { name, ...(image !== undefined ? { image } : {}), providers, endpoints, allowGitRemote }
132
+ }
133
+
134
+ /** Normalizes untrusted node.json `openshell.profiles`; the default profile is always there unless unsupported. */
135
+ export function normalizeOpenShellProfiles(raw: unknown): OpenShellProfiles {
136
+ const problems: string[] = []
137
+ const profiles: Record<string, OpenShellProfile> = { [DEFAULT_PROFILE]: builtinDefault() }
138
+ const unsupported: Record<string, string> = {}
139
+ if (raw !== undefined && !isObject(raw)) problems.push("openshell.profiles must be an object of profile name to profile")
140
+
141
+ let entries = Object.entries(isObject(raw) ? raw : {})
142
+ if (entries.length > PROFILES_MAX) {
143
+ problems.push(`openshell.profiles: at most ${PROFILES_MAX} profiles; the rest are ignored`)
144
+ entries = entries.slice(0, PROFILES_MAX)
145
+ }
146
+ for (const [name, value] of entries) {
147
+ if (!token(name, NAME_MAX)) {
148
+ problems.push(`openshell.profiles: ${show(name)} is not a profile name (lowercase letters, digits, . _ -; at most ${NAME_MAX})`)
149
+ continue
150
+ }
151
+ if (!isObject(value)) {
152
+ problems.push(`openshell.profiles.${name} must be an object`)
153
+ continue
154
+ }
155
+ const asked = UNSUPPORTED_FIELDS.filter((field) => value[field] !== undefined)
156
+ if (asked.length > 0) {
157
+ const reason = `profile ${name} sets ${asked.join(", ")}, which the OpenShell vm backend does not support`
158
+ unsupported[name] = reason
159
+ delete profiles[name]
160
+ problems.push(`openshell.profiles.${name}: unsupported (${reason})`)
161
+ continue
162
+ }
163
+ profiles[name] = normalizeProfile(name, value, problems)
164
+ }
165
+ return { profiles, unsupported, problems }
166
+ }
167
+
168
+ /** The profile a launch asked for (or the default); ProfileError when it is unknown or unsupported here. */
169
+ export function selectProfile(set: OpenShellProfiles, name: string | undefined): OpenShellProfile {
170
+ const wanted = name ?? DEFAULT_PROFILE
171
+ const reason = set.unsupported[wanted]
172
+ if (reason !== undefined) throw new ProfileError("unsupported", reason)
173
+ const profile = set.profiles[wanted]
174
+ if (!profile) throw new ProfileError("not_found", `this node has no OpenShell profile ${wanted}`)
175
+ return profile
176
+ }
177
+
178
+ /** Usable profile names, sorted: what the node's hello reports for the openshell runtime. */
179
+ export function profileNames(set: OpenShellProfiles): string[] {
180
+ return Object.keys(set.profiles).sort()
181
+ }