@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,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
|
+
}
|