@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.
- 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,144 @@
|
|
|
1
|
+
// Harness presets (design §1.2, preset tier): data-only descriptions of a harness the hub drives through a terminal
|
|
2
|
+
// pane (Herdr/tmux) when it has no spoke of its own. A preset is data, never code: an argv list (no shell syntax,
|
|
3
|
+
// nothing is run through a shell), an optional resume flag, where the harness's native session ID comes from, and
|
|
4
|
+
// fixed delivery "paste" (messages are pasted into the pane, so a deliver is at most "queued") and evidence "screen"
|
|
5
|
+
// (activity is sampled from the screen, which never counts as idle). Unknown fields are refused, so a typo or a field
|
|
6
|
+
// from a newer schema is an error rather than silently ignored. The built-in harness names are reserved.
|
|
7
|
+
//
|
|
8
|
+
// Imports only from wire.ts: index.ts re-exports this module.
|
|
9
|
+
|
|
10
|
+
import { isObj, nonEmpty, token, UNSAFE_CHARS } from "./wire.ts"
|
|
11
|
+
|
|
12
|
+
/** Harnesses with their own spokes; a preset may not take their names. */
|
|
13
|
+
export const RESERVED_HARNESS_NAMES = ["opencode", "claude-code", "codex"] as const
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a preset's native session ID comes from: nowhere (it cannot resume), a flag the hub passes a fresh ID with
|
|
17
|
+
* when it starts the harness (e.g. `--session-id <id>`), or the first capture group of a pattern on the screen.
|
|
18
|
+
*/
|
|
19
|
+
export type PresetNativeID =
|
|
20
|
+
| { readonly source: "none" }
|
|
21
|
+
| { readonly source: "argv"; readonly flag: string }
|
|
22
|
+
| { readonly source: "screen"; readonly pattern: string }
|
|
23
|
+
|
|
24
|
+
export interface HarnessPreset {
|
|
25
|
+
/** The harness id (a lowercase token, as a hello's `harness`), not one of RESERVED_HARNESS_NAMES. */
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly description?: string
|
|
28
|
+
/** The argv that starts the harness: the program, then its arguments. Never run through a shell. */
|
|
29
|
+
readonly command: readonly string[]
|
|
30
|
+
/** The flag that resumes a session: the hub appends it and the native ID (`[...command, resumeFlag, id]`). */
|
|
31
|
+
readonly resumeFlag?: string
|
|
32
|
+
readonly nativeID: PresetNativeID
|
|
33
|
+
readonly delivery: "paste"
|
|
34
|
+
readonly evidence: "screen"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PRESET_FIELDS = ["name", "description", "command", "resumeFlag", "nativeID", "delivery", "evidence"] as const
|
|
38
|
+
|
|
39
|
+
export const PRESET_DESCRIPTION_MAX = 300
|
|
40
|
+
export const PRESET_ARGS_MAX = 64
|
|
41
|
+
export const PRESET_ARG_MAX = 1000
|
|
42
|
+
export const PRESET_PATTERN_MAX = 500
|
|
43
|
+
export const PRESET_FLAG_MAX = 100
|
|
44
|
+
|
|
45
|
+
/** Characters that mean shell syntax (substitution, chaining, redirection, quoting, escapes). */
|
|
46
|
+
const SHELL_SYNTAX = /[`$;|&<>\\'"]/
|
|
47
|
+
const FLAG = new RegExp(`^--?[A-Za-z0-9][A-Za-z0-9-]{0,${PRESET_FLAG_MAX - 3}}$`)
|
|
48
|
+
const isFlag = (v: unknown): v is string => typeof v === "string" && FLAG.test(v)
|
|
49
|
+
|
|
50
|
+
/** Why an argv element is not acceptable, or undefined. */
|
|
51
|
+
function argProblem(v: unknown, i: number): string | undefined {
|
|
52
|
+
const label = `command[${i}]`
|
|
53
|
+
if (!nonEmpty(v) || v.length > PRESET_ARG_MAX) return `${label} must be 1–${PRESET_ARG_MAX} characters`
|
|
54
|
+
if (UNSAFE_CHARS.test(v)) return `${label} must not contain control characters or line breaks`
|
|
55
|
+
if (SHELL_SYNTAX.test(v)) return `${label} must not contain shell syntax (\` $ ; | & < > \\ ' "): command is an argv list, never run through a shell`
|
|
56
|
+
if (v.startsWith("~")) return `${label} must not start with ~ (no shell expansion)`
|
|
57
|
+
if (i === 0 && /\s/.test(v)) return "command[0] is the program alone: put its arguments in the next items"
|
|
58
|
+
if (i === 0 && v.startsWith("-")) return "command[0] must be the program, not a flag"
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The number of capture groups in `pattern`, or undefined when it does not compile. */
|
|
63
|
+
function captureGroups(pattern: string): number | undefined {
|
|
64
|
+
try {
|
|
65
|
+
return new RegExp(`${pattern}|`).exec("")!.length - 1
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nativeIDProblem(v: unknown): string | undefined {
|
|
72
|
+
if (!isObj(v)) return "nativeID must be {source: none | argv | screen, …}"
|
|
73
|
+
const allowed: Record<string, readonly string[]> = { none: ["source"], argv: ["source", "flag"], screen: ["source", "pattern"] }
|
|
74
|
+
const keys = typeof v.source === "string" ? allowed[v.source] : undefined
|
|
75
|
+
if (!keys) return "nativeID.source must be none, argv or screen"
|
|
76
|
+
const unknown = Object.keys(v).find((k) => !keys.includes(k))
|
|
77
|
+
if (unknown) return `nativeID.${unknown} is not a field of a ${v.source} nativeID`
|
|
78
|
+
if (v.source === "argv" && !isFlag(v.flag)) return "nativeID.flag must be one flag, e.g. --session-id"
|
|
79
|
+
if (v.source === "screen") {
|
|
80
|
+
if (!nonEmpty(v.pattern) || v.pattern.length > PRESET_PATTERN_MAX) return `nativeID.pattern must be 1–${PRESET_PATTERN_MAX} characters`
|
|
81
|
+
const groups = captureGroups(v.pattern)
|
|
82
|
+
if (groups === undefined) return "nativeID.pattern must be a valid regular expression"
|
|
83
|
+
if (groups < 1) return "nativeID.pattern must capture the ID in a group"
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Why `v` is not an acceptable harness preset, or undefined when it is. */
|
|
89
|
+
export function presetProblem(v: unknown): string | undefined {
|
|
90
|
+
if (!isObj(v)) return "a preset must be an object"
|
|
91
|
+
const unknown = Object.keys(v).find((k) => !(PRESET_FIELDS as readonly string[]).includes(k))
|
|
92
|
+
if (unknown) return `${unknown} is not a preset field (fields: ${PRESET_FIELDS.join(", ")})`
|
|
93
|
+
if (!token(v.name, 64)) return "name must be a lowercase token of 1–64 letters, digits and . _ -"
|
|
94
|
+
if ((RESERVED_HARNESS_NAMES as readonly string[]).includes(v.name)) return `name ${v.name} is reserved for a built-in harness`
|
|
95
|
+
if (v.description !== undefined && (typeof v.description !== "string" || v.description.length > PRESET_DESCRIPTION_MAX || UNSAFE_CHARS.test(v.description))) {
|
|
96
|
+
return `description must be at most ${PRESET_DESCRIPTION_MAX} printable characters`
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(v.command) || v.command.length === 0 || v.command.length > PRESET_ARGS_MAX) return `command must be a list of 1–${PRESET_ARGS_MAX} arguments (argv), not a string`
|
|
99
|
+
for (const [i, arg] of v.command.entries()) {
|
|
100
|
+
const problem = argProblem(arg, i)
|
|
101
|
+
if (problem) return problem
|
|
102
|
+
}
|
|
103
|
+
const native = nativeIDProblem(v.nativeID)
|
|
104
|
+
if (native) return native
|
|
105
|
+
if (v.resumeFlag !== undefined) {
|
|
106
|
+
if (!isFlag(v.resumeFlag)) return "resumeFlag must be one flag, e.g. --resume"
|
|
107
|
+
if ((v.nativeID as PresetNativeID).source === "none") return "resumeFlag needs a nativeID source (argv or screen) to resume by"
|
|
108
|
+
}
|
|
109
|
+
if (v.delivery !== "paste") return "delivery must be paste"
|
|
110
|
+
if (v.evidence !== "screen") return "evidence must be screen"
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether `v` is an acceptable harness preset (see presetProblem). */
|
|
115
|
+
export const isHarnessPreset = (v: unknown): v is HarnessPreset => presetProblem(v) === undefined
|
|
116
|
+
|
|
117
|
+
/** The preset schema as JSON Schema (draft 2020-12), for packs validated outside TypeScript. */
|
|
118
|
+
export const PRESET_JSON_SCHEMA = {
|
|
119
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
120
|
+
title: "Hub harness preset",
|
|
121
|
+
type: "object",
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
required: ["name", "command", "nativeID", "delivery", "evidence"],
|
|
124
|
+
properties: {
|
|
125
|
+
name: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]*$", maxLength: 64, not: { enum: [...RESERVED_HARNESS_NAMES] } },
|
|
126
|
+
description: { type: "string", maxLength: PRESET_DESCRIPTION_MAX },
|
|
127
|
+
command: {
|
|
128
|
+
type: "array",
|
|
129
|
+
minItems: 1,
|
|
130
|
+
maxItems: PRESET_ARGS_MAX,
|
|
131
|
+
items: { type: "string", minLength: 1, maxLength: PRESET_ARG_MAX, pattern: "^[^`$;|&<>\\\\'\"~][^`$;|&<>\\\\'\"]*$" },
|
|
132
|
+
},
|
|
133
|
+
resumeFlag: { type: "string", pattern: FLAG.source },
|
|
134
|
+
nativeID: {
|
|
135
|
+
oneOf: [
|
|
136
|
+
{ type: "object", additionalProperties: false, required: ["source"], properties: { source: { const: "none" } } },
|
|
137
|
+
{ type: "object", additionalProperties: false, required: ["source", "flag"], properties: { source: { const: "argv" }, flag: { type: "string", pattern: FLAG.source } } },
|
|
138
|
+
{ type: "object", additionalProperties: false, required: ["source", "pattern"], properties: { source: { const: "screen" }, pattern: { type: "string", minLength: 1, maxLength: PRESET_PATTERN_MAX } } },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
delivery: { const: "paste" },
|
|
142
|
+
evidence: { const: "screen" },
|
|
143
|
+
},
|
|
144
|
+
} as const
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Webhook signatures (design §6.1; decision znbg8xa5 #8). Every webhook POST carries
|
|
2
|
+
// Hub-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the registration's secret>
|
|
3
|
+
// plus Hub-Event (the event type) and Hub-Delivery (a delivery ID, the same on every retry, for deduplication).
|
|
4
|
+
// While a secret rotates the header carries one v1 per secret; a receiver accepts any match. Receivers refuse a
|
|
5
|
+
// timestamp outside WEBHOOK_TOLERANCE_SECONDS (replay) and compare signatures in constant time.
|
|
6
|
+
// Only the format lives here (no crypto): the hub signs, the SDK verifies.
|
|
7
|
+
|
|
8
|
+
export const WEBHOOK_SIGNATURE_HEADER = "Hub-Signature"
|
|
9
|
+
export const WEBHOOK_EVENT_HEADER = "Hub-Event"
|
|
10
|
+
export const WEBHOOK_DELIVERY_HEADER = "Hub-Delivery"
|
|
11
|
+
export const WEBHOOK_SIGNATURE_SCHEME = "v1"
|
|
12
|
+
/** The HMAC hash (node:crypto createHmac name). */
|
|
13
|
+
export const WEBHOOK_SIGNATURE_ALGORITHM = "sha256"
|
|
14
|
+
/** Largest gap, either way, between a signature's timestamp and the receiver's clock. */
|
|
15
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300
|
|
16
|
+
/** Longest Hub-Signature header a receiver parses. */
|
|
17
|
+
export const WEBHOOK_SIGNATURE_MAX = 2048
|
|
18
|
+
|
|
19
|
+
export interface WebhookSignature {
|
|
20
|
+
/** Unix seconds. */
|
|
21
|
+
readonly t: number
|
|
22
|
+
/** Hex HMAC-SHA256 values, one per active secret. */
|
|
23
|
+
readonly v1: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEX_SHA256 = /^[0-9a-f]{64}$/
|
|
27
|
+
|
|
28
|
+
/** The bytes the signature covers: `<t>.<raw body>`. */
|
|
29
|
+
export function webhookSignedPayload(t: number, body: string): string {
|
|
30
|
+
return `${t}.${body}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The Hub-Signature header value for timestamp `t` (unix seconds) and hex signatures. */
|
|
34
|
+
export function formatWebhookSignature(t: number, signatures: readonly string[]): string {
|
|
35
|
+
return [`t=${t}`, ...signatures.map((s) => `${WEBHOOK_SIGNATURE_SCHEME}=${s}`)].join(",")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A Hub-Signature header's timestamp and v1 signatures, or undefined when it is malformed. Unknown schemes are ignored. */
|
|
39
|
+
export function parseWebhookSignature(header: unknown): WebhookSignature | undefined {
|
|
40
|
+
if (typeof header !== "string" || header.length === 0 || header.length > WEBHOOK_SIGNATURE_MAX) return undefined
|
|
41
|
+
let t: number | undefined
|
|
42
|
+
const v1: string[] = []
|
|
43
|
+
for (const part of header.split(",")) {
|
|
44
|
+
const eq = part.indexOf("=")
|
|
45
|
+
if (eq < 0) return undefined
|
|
46
|
+
const key = part.slice(0, eq).trim()
|
|
47
|
+
const value = part.slice(eq + 1).trim()
|
|
48
|
+
if (key === "t") {
|
|
49
|
+
if (t !== undefined || !/^\d{1,12}$/.test(value)) return undefined
|
|
50
|
+
t = Number(value)
|
|
51
|
+
} else if (key === WEBHOOK_SIGNATURE_SCHEME) {
|
|
52
|
+
if (!HEX_SHA256.test(value)) return undefined
|
|
53
|
+
v1.push(value)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return t !== undefined && v1.length > 0 ? { t, v1 } : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether timestamp `t` (unix seconds) is within `tolerance` seconds of `nowMs`. */
|
|
60
|
+
export function webhookTimestampFresh(t: number, nowMs: number = Date.now(), tolerance: number = WEBHOOK_TOLERANCE_SECONDS): boolean {
|
|
61
|
+
return Math.abs(nowMs / 1000 - t) <= tolerance
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Shared frame primitives for the spoke protocol (index.ts), the node protocol (node.ts) and launch checks
|
|
2
|
+
// (launch.ts). Kept apart so those modules import values from here, never from each other in a cycle.
|
|
3
|
+
|
|
4
|
+
/** Largest frame (in characters) either side accepts; a larger one is a protocol error. */
|
|
5
|
+
export const MAX_FRAME = 1_000_000
|
|
6
|
+
|
|
7
|
+
export const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
8
|
+
export const str = (v: unknown): v is string => typeof v === "string"
|
|
9
|
+
export const nonEmpty = (v: unknown): v is string => typeof v === "string" && v.length > 0
|
|
10
|
+
export const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
11
|
+
/** A plain id: letters, digits and `_.:@/+~-`, 1–300 characters. */
|
|
12
|
+
export const ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
13
|
+
/** A short lowercase token (harness id, evidence level, runtime kind). */
|
|
14
|
+
export const token = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max && /^[a-z0-9][a-z0-9._-]*$/.test(v)
|
|
15
|
+
/** A feature list: at most 64 names of 1–64 characters. */
|
|
16
|
+
export const featureList = (v: unknown): v is string[] => Array.isArray(v) && v.length <= 64 && v.every((f) => nonEmpty(f) && f.length <= 64)
|
|
17
|
+
/** Control (C0, C1), format (bidi, zero-width, BOM) and line/paragraph separator characters. */
|
|
18
|
+
export const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\u2028\u2029]/u
|
|
19
|
+
|
|
20
|
+
export const isResFrame = (v: Record<string, unknown>): boolean => nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
|
|
21
|
+
/** Longest reconnect delay a bye's retryAfterMs may ask for (5 minutes). */
|
|
22
|
+
export const MAX_RETRY_AFTER_MS = 300_000
|
|
23
|
+
/** A bye: a reason, and optionally (e.g. a draining hub) how soon to reconnect, in whole milliseconds. */
|
|
24
|
+
export const isByeFrame = (v: Record<string, unknown>): boolean =>
|
|
25
|
+
str(v.reason) &&
|
|
26
|
+
(v.retryAfterMs === undefined || (Number.isInteger(v.retryAfterMs) && (v.retryAfterMs as number) >= 0 && (v.retryAfterMs as number) <= MAX_RETRY_AFTER_MS))
|
|
27
|
+
export const isWelcomeFrame = (v: Record<string, unknown>): boolean =>
|
|
28
|
+
num(v.v) && nonEmpty(v.machineID) && str(v.publicURL) && (v.features === undefined || featureList(v.features))
|
|
29
|
+
|
|
30
|
+
/** Longest hub URL a sessionTokens request or a start op carries. */
|
|
31
|
+
export const HUB_URL_MAX_CHARS = 2048
|
|
32
|
+
|
|
33
|
+
/** An http(s) URL of at most HUB_URL_MAX_CHARS characters. */
|
|
34
|
+
export function isHubURL(v: unknown): v is string {
|
|
35
|
+
if (!nonEmpty(v) || v.length > HUB_URL_MAX_CHARS) return false
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(v)
|
|
38
|
+
return url.protocol === "http:" || url.protocol === "https:"
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A frame's JSON object, or undefined when it is too large, not JSON or not an object. */
|
|
45
|
+
export function parseFrame(raw: string | Buffer): Record<string, unknown> | undefined {
|
|
46
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8")
|
|
47
|
+
if (text.length > MAX_FRAME) return undefined
|
|
48
|
+
try {
|
|
49
|
+
const value = JSON.parse(text)
|
|
50
|
+
return isObj(value) ? value : undefined
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The hub sandbox image: what an OpenShell sandbox of `hub node` runs (packages/runtime-openshell; spike report
|
|
2
|
+
# gut64rwq). Build it with build.ts, never from the repository root: the context is a staged directory holding
|
|
3
|
+
# bin/opencode the pinned Linux OpenCode build (npm @opencode/cli-linux-<arch>@<OPENCODE_VERSION>)
|
|
4
|
+
# bin/hub-sandbox-start bin/hub-sandbox-entry (entry.sh, start.sh)
|
|
5
|
+
# lib/hub/node_modules/@huynle/hub the hub plugin, installed from this repository's package
|
|
6
|
+
# bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG]
|
|
7
|
+
# The runtime creates the sandbox with no main command (it stays Ready) and execs /usr/local/bin/hub-sandbox-start,
|
|
8
|
+
# which waits for /sandbox/.hub/config.json and .hub/ready and runs `opencode serve --service`. OpenShell runs it as
|
|
9
|
+
# its own user (HOME=/sandbox, the only writable place with /tmp) and injects its proxy CA through the Node and
|
|
10
|
+
# OpenSSL CA environment variables, which nothing here may set or clear.
|
|
11
|
+
FROM debian:bookworm-slim
|
|
12
|
+
|
|
13
|
+
ARG HUB_VERSION=dev
|
|
14
|
+
ARG OPENCODE_VERSION=dev
|
|
15
|
+
LABEL org.opencontainers.image.title="hub-sandbox" \
|
|
16
|
+
org.opencontainers.image.description="Hub sandbox: OpenCode with the hub plugin, for hub node's OpenShell runtime" \
|
|
17
|
+
org.opencontainers.image.licenses="MIT" \
|
|
18
|
+
org.opencontainers.image.source="https://github.com/huynle/hub" \
|
|
19
|
+
org.opencontainers.image.version="${HUB_VERSION}" \
|
|
20
|
+
io.github.huynle.hub.opencode-version="${OPENCODE_VERSION}"
|
|
21
|
+
|
|
22
|
+
RUN apt-get update \
|
|
23
|
+
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git bash ca-certificates curl procps \
|
|
24
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
25
|
+
|
|
26
|
+
COPY bin/ /usr/local/bin/
|
|
27
|
+
COPY lib/hub/ /usr/local/lib/hub/
|
|
28
|
+
RUN chmod 0755 /usr/local/bin/opencode /usr/local/bin/hub-sandbox-start /usr/local/bin/hub-sandbox-entry \
|
|
29
|
+
&& chmod -R a+rX /usr/local/lib/hub
|
|
30
|
+
|
|
31
|
+
# The harness stays offline but for the hub and its providers (decision lk6gn3u7 §7); the entry script sets them
|
|
32
|
+
# again, so the bootstrap fallback on another image gets them too.
|
|
33
|
+
ENV OPENCODE_DISABLE_MODELS_FETCH=1 \
|
|
34
|
+
OPENCODE_DISABLE_AUTOUPDATE=1 \
|
|
35
|
+
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
|
|
36
|
+
HUB_SANDBOX_IMAGE=${HUB_VERSION}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# hub sandbox image
|
|
2
|
+
|
|
3
|
+
What an OpenShell sandbox of `hub node` runs (design §6.4, decision lk6gn3u7, spike report gut64rwq): Debian
|
|
4
|
+
bookworm-slim with git, bash, ca-certificates, curl and procps, the pinned Linux OpenCode
|
|
5
|
+
(`@opencode/cli-linux-<arch>@OPENCODE_VERSION` from npm, checked against the registry's sha512), the hub plugin
|
|
6
|
+
(this repository's package, installed with its production dependencies) and two scripts.
|
|
7
|
+
|
|
8
|
+
| File | What |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `image.ts` | Names, layout and the commands the runtime and `build.ts` run (pure) |
|
|
11
|
+
| `build.ts` | Stages the layout, builds the image (`docker build`), or packs the bootstrap tarball |
|
|
12
|
+
| `Dockerfile` | The image, built from the staged context only |
|
|
13
|
+
| `start.sh` → `bin/hub-sandbox-start` | Starts the entry detached (`setsid nohup`, log `.hub/harness.log`) and returns |
|
|
14
|
+
| `entry.sh` → `bin/hub-sandbox-entry` | Waits for the start op, runs and supervises `opencode serve --service` |
|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
The same under both roots, so one entry script serves both:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
<root>/bin/opencode IMAGE_ROOT = /usr/local (the image)
|
|
22
|
+
<root>/bin/hub-sandbox-start BOOTSTRAP_ROOT = /sandbox/.hub/opt (the upload fallback)
|
|
23
|
+
<root>/bin/hub-sandbox-entry
|
|
24
|
+
<root>/lib/hub/node_modules/@huynle/hub the plugin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Contract with the runtime
|
|
28
|
+
|
|
29
|
+
1. Create the sandbox **without a main command** (it stays Ready; a main command that ends moves it to Completed and
|
|
30
|
+
exec is refused).
|
|
31
|
+
2. Exec `startCommand(root)` (`/usr/local/bin/hub-sandbox-start`) at any point; it returns at once and a second call
|
|
32
|
+
does nothing while the entry runs.
|
|
33
|
+
3. Upload `.hub/config.json` (`{hubURL, token, launchID}`, read by the plugin's spoke, never by the scripts),
|
|
34
|
+
optionally `.hub/env.json` (a JSON object of extra variables), then touch `.hub/ready`. The entry waits for both
|
|
35
|
+
`config.json` and `ready`.
|
|
36
|
+
4. The entry sets `OPENCODE_DISABLE_MODELS_FETCH/AUTOUPDATE/PROJECT_CONFIG=1`, `XDG_*_HOME=/sandbox/.xdg/*`,
|
|
37
|
+
`HUB_LAUNCH_CONFIG`, a random `OPENCODE_PASSWORD` unless given, and writes the global OpenCode config loading the
|
|
38
|
+
plugin. `env.json` cannot override those, `PATH`, `HOME`, `BUN_*`/`LD_*` or OpenShell's CA variables
|
|
39
|
+
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, …), which are kept as OpenShell injected them: Bun trusts the proxy CA only
|
|
40
|
+
through them. An invalid `env.json` ends the entry with status 2.
|
|
41
|
+
5. It runs `opencode serve --service --hostname 127.0.0.1 --port 4096` in `/sandbox/work` (else `/sandbox`) and sends
|
|
42
|
+
one authorized request so the server loads its instance and the plugin.
|
|
43
|
+
6. `.hub/stop` holding `graceful` (SIGTERM, SIGKILL after `HUB_STOP_GRACE`, 10 s) or `kill` (SIGKILL) stops it;
|
|
44
|
+
`.hub/exited` then holds the exit code. `.hub/entry.pid` and `.hub/harness.pid` exist while they run.
|
|
45
|
+
|
|
46
|
+
## Build
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG] # docker build, never pushes
|
|
50
|
+
bun packages/runtime-openshell/image/build.ts bootstrap [--arch arm64|x64] [--out FILE.tgz]
|
|
51
|
+
bun packages/runtime-openshell/image/build.ts stage --out DIR [--arch arm64|x64]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The default tag is `hub-sandbox:<hub version>-opencode<OpenCode version>`; the architecture defaults to the host's.
|
|
55
|
+
`HUB_IT_DOCKER=1 bun test packages/runtime-openshell/test/image-docker.it.test.ts` builds it under a throwaway tag,
|
|
56
|
+
checks it and removes it.
|
|
57
|
+
|
|
58
|
+
## Publishing (deliberate, not automated)
|
|
59
|
+
|
|
60
|
+
OpenShell's vm driver does **not** use local Docker images: it pulls from an **HTTPS registry** (a plain-http local
|
|
61
|
+
registry is refused, rootfs tarballs need the gateway's `allow_driver_config`, and local images only work when the
|
|
62
|
+
gateway itself sees a container engine through `DOCKER_HOST` or `/var/run/docker.sock`, an operator choice). To use the
|
|
63
|
+
image, publish it, for example:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
bun packages/runtime-openshell/image/build.ts image --arch arm64 --tag ghcr.io/<owner>/hub-sandbox:<tag>
|
|
67
|
+
docker push ghcr.io/<owner>/hub-sandbox:<tag> # or a multi-arch manifest built per architecture
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
and point the node at it (`openshell.profiles.<name>.image` in node.json, or the runtime's default image).
|
|
71
|
+
|
|
72
|
+
## Upload fallback (gateways that cannot pull the image)
|
|
73
|
+
|
|
74
|
+
On the gateway's default image (`nvcr.io/nvidia/base/ubuntu:24.04`; `/usr` read-only), the runtime uploads the
|
|
75
|
+
bootstrap tarball under `/sandbox/.hub/`, runs `bootstrapCommands(uploaded)` (unpack into `/sandbox/.hub/opt`, remove
|
|
76
|
+
the tarball) and then `startCommand(BOOTSTRAP_ROOT)`. Two limits: the sandbox policy must allow
|
|
77
|
+
`harnessBinary(BOOTSTRAP_ROOT)` (`/sandbox/.hub/opt/bin/opencode`) instead of `/usr/local/bin/opencode`, and that
|
|
78
|
+
image has no git (the runtime's clone and collect need it), so the fallback needs git from elsewhere.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Builds the hub sandbox image, or the bootstrap tarball of the upload fallback (see README.md next to this file).
|
|
3
|
+
//
|
|
4
|
+
// bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG]
|
|
5
|
+
// bun packages/runtime-openshell/image/build.ts bootstrap [--arch arm64|x64] [--out FILE.tgz]
|
|
6
|
+
// bun packages/runtime-openshell/image/build.ts stage --out DIR [--arch arm64|x64]
|
|
7
|
+
//
|
|
8
|
+
// All three stage the same layout (image.ts): bin/opencode (the pinned Linux OpenCode from the npm registry, checked
|
|
9
|
+
// against the registry's sha512 integrity), bin/hub-sandbox-start and bin/hub-sandbox-entry (start.sh, entry.sh) and
|
|
10
|
+
// lib/hub/node_modules/@huynle/hub (this repository packed with `npm pack --ignore-scripts` and installed with its
|
|
11
|
+
// production dependencies). `image` adds the Dockerfile and runs `docker build` for one platform; it never pushes
|
|
12
|
+
// (OpenShell's vm driver pulls from an HTTPS registry, not from local Docker: publishing is a separate, deliberate
|
|
13
|
+
// step, see README.md). `bootstrap` packs the layout as a .tgz for the runtime to upload into /sandbox/.hub/opt.
|
|
14
|
+
import { createHash } from "node:crypto"
|
|
15
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
import {
|
|
19
|
+
ENTRY_SCRIPT,
|
|
20
|
+
OPENCODE_VERSION,
|
|
21
|
+
PLUGIN_SUBPATH,
|
|
22
|
+
START_SCRIPT,
|
|
23
|
+
bootstrapTarballName,
|
|
24
|
+
dockerBuildArgs,
|
|
25
|
+
imageTag,
|
|
26
|
+
linuxArch,
|
|
27
|
+
opencodeMetadataURL,
|
|
28
|
+
opencodeTarballURL,
|
|
29
|
+
type LinuxArch,
|
|
30
|
+
} from "./image.ts"
|
|
31
|
+
|
|
32
|
+
/** Runs a command (argv, no shell) and returns its stdout; throws on a non-zero exit. */
|
|
33
|
+
export type Run = (cmd: readonly string[], cwd?: string) => Promise<string>
|
|
34
|
+
|
|
35
|
+
const HERE = import.meta.dir
|
|
36
|
+
const REPO_ROOT = path.join(HERE, "..", "..", "..")
|
|
37
|
+
const REGISTRY = "https://registry.npmjs.org/"
|
|
38
|
+
|
|
39
|
+
export const run: Run = async (cmd, cwd) => {
|
|
40
|
+
// COPYFILE_DISABLE: macOS tar would add AppleDouble ._ files to the bootstrap tarball.
|
|
41
|
+
const p = Bun.spawn([...cmd], { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "pipe", stderr: "pipe" })
|
|
42
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
43
|
+
if (code !== 0) throw new Error(`${cmd.join(" ")} failed (exit ${code}): ${err.trim().split("\n").slice(-5).join("\n")}`)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Throws unless `bytes` match an npm sha512 subresource-integrity string. */
|
|
48
|
+
export function verifyIntegrity(bytes: Uint8Array, integrity: string): void {
|
|
49
|
+
const sha512 = integrity.split(/\s+/).find((s) => s.startsWith("sha512-"))
|
|
50
|
+
if (!sha512) throw new Error(`expected a sha512 integrity, got ${JSON.stringify(integrity)}`)
|
|
51
|
+
const actual = `sha512-${createHash("sha512").update(bytes).digest("base64")}`
|
|
52
|
+
if (actual !== sha512) throw new Error(`integrity mismatch: expected ${sha512}, got ${actual}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BuildOptions {
|
|
56
|
+
readonly arch: LinuxArch
|
|
57
|
+
/** This repository (default: three levels up from this file). */
|
|
58
|
+
readonly repoRoot?: string
|
|
59
|
+
readonly opencodeVersion?: string
|
|
60
|
+
readonly fetch?: typeof fetch
|
|
61
|
+
readonly run?: Run
|
|
62
|
+
readonly log?: (line: string) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StageOptions extends BuildOptions {
|
|
66
|
+
/** The context directory: created, or empty. */
|
|
67
|
+
readonly dir: string
|
|
68
|
+
/** Copy the Dockerfile too (default true). */
|
|
69
|
+
readonly dockerfile?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stages the layout into `dir`; returns the hub version packed. */
|
|
73
|
+
export async function stage(o: StageOptions): Promise<{ dir: string; hubVersion: string }> {
|
|
74
|
+
const exec = o.run ?? run
|
|
75
|
+
const get = o.fetch ?? fetch
|
|
76
|
+
const log = o.log ?? (() => {})
|
|
77
|
+
const repoRoot = o.repoRoot ?? REPO_ROOT
|
|
78
|
+
const version = o.opencodeVersion ?? OPENCODE_VERSION
|
|
79
|
+
if (existsSync(o.dir) && readdirSync(o.dir).length > 0) throw new Error(`the context directory ${o.dir} is not empty`)
|
|
80
|
+
const bin = path.join(o.dir, "bin")
|
|
81
|
+
mkdirSync(bin, { recursive: true })
|
|
82
|
+
const work = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "hub-sandbox-stage-"))
|
|
83
|
+
try {
|
|
84
|
+
// The Linux OpenCode build, checked against the registry's integrity.
|
|
85
|
+
log(`fetching OpenCode ${version} for linux-${o.arch}`)
|
|
86
|
+
const meta = await get(opencodeMetadataURL(o.arch, version))
|
|
87
|
+
if (!meta.ok) throw new Error(`npm registry: ${meta.status} for ${opencodeMetadataURL(o.arch, version)}`)
|
|
88
|
+
const dist = ((await meta.json()) as { dist?: { tarball?: unknown; integrity?: unknown } }).dist
|
|
89
|
+
if (typeof dist?.tarball !== "string" || typeof dist.integrity !== "string") throw new Error("npm registry: no dist.tarball/dist.integrity")
|
|
90
|
+
if (!dist.tarball.startsWith(REGISTRY)) throw new Error(`refusing an OpenCode tarball outside the npm registry: ${dist.tarball}`)
|
|
91
|
+
const res = await get(dist.tarball)
|
|
92
|
+
if (!res.ok) throw new Error(`npm registry: ${res.status} for ${dist.tarball}`)
|
|
93
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
94
|
+
verifyIntegrity(bytes, dist.integrity)
|
|
95
|
+
const tgz = path.join(work, "opencode.tgz")
|
|
96
|
+
writeFileSync(tgz, bytes)
|
|
97
|
+
await exec(["tar", "-xzf", tgz, "-C", work, "package/bin/opencode"])
|
|
98
|
+
copyFileSync(path.join(work, "package/bin/opencode"), path.join(bin, "opencode"))
|
|
99
|
+
chmodSync(path.join(bin, "opencode"), 0o755)
|
|
100
|
+
|
|
101
|
+
// The scripts.
|
|
102
|
+
for (const [name, src] of [
|
|
103
|
+
[START_SCRIPT, "start.sh"],
|
|
104
|
+
[ENTRY_SCRIPT, "entry.sh"],
|
|
105
|
+
] as const) {
|
|
106
|
+
copyFileSync(path.join(HERE, src), path.join(bin, name))
|
|
107
|
+
chmodSync(path.join(bin, name), 0o755)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The hub plugin: this repository as its npm package, with production dependencies.
|
|
111
|
+
log("packing the hub plugin")
|
|
112
|
+
const packed = JSON.parse(await exec(["npm", "pack", "--ignore-scripts", "--json", "--pack-destination", work], repoRoot)) as Array<{ filename: string }>
|
|
113
|
+
const file = packed[0]?.filename
|
|
114
|
+
if (!file) throw new Error("npm pack reported no file")
|
|
115
|
+
const prefix = path.join(o.dir, "lib/hub")
|
|
116
|
+
mkdirSync(prefix, { recursive: true })
|
|
117
|
+
await exec(["npm", "install", "--prefix", prefix, "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", path.join(work, path.basename(file))], work)
|
|
118
|
+
if (!existsSync(path.join(o.dir, PLUGIN_SUBPATH, "server.ts"))) throw new Error(`the plugin was not installed at ${PLUGIN_SUBPATH}`)
|
|
119
|
+
// The prefix's package.json only names the temporary tarball.
|
|
120
|
+
rmSync(path.join(prefix, "package.json"), { force: true })
|
|
121
|
+
|
|
122
|
+
if (o.dockerfile !== false) copyFileSync(path.join(HERE, "Dockerfile"), path.join(o.dir, "Dockerfile"))
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(work, { recursive: true, force: true })
|
|
125
|
+
}
|
|
126
|
+
const hubVersion = (JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { version: string }).version
|
|
127
|
+
return { dir: o.dir, hubVersion }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function tempDir(prefix: string): string {
|
|
131
|
+
return mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), prefix))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Builds the image locally (docker build); never pushes. */
|
|
135
|
+
export async function buildImage(o: BuildOptions & { tag?: string }): Promise<{ tag: string }> {
|
|
136
|
+
const exec = o.run ?? run
|
|
137
|
+
const dir = tempDir("hub-sandbox-image-")
|
|
138
|
+
try {
|
|
139
|
+
const { hubVersion } = await stage({ ...o, dir })
|
|
140
|
+
const tag = o.tag ?? imageTag(hubVersion, o.opencodeVersion)
|
|
141
|
+
o.log?.(`docker build ${tag}`)
|
|
142
|
+
await exec(["docker", ...dockerBuildArgs({ tag, arch: o.arch, context: dir, hubVersion, ...(o.opencodeVersion ? { opencodeVersion: o.opencodeVersion } : {}) })])
|
|
143
|
+
return { tag }
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(dir, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Packs the layout as the bootstrap tarball (default name in the current directory). */
|
|
150
|
+
export async function buildBootstrap(o: BuildOptions & { out?: string }): Promise<{ out: string }> {
|
|
151
|
+
const exec = o.run ?? run
|
|
152
|
+
const dir = tempDir("hub-sandbox-bootstrap-")
|
|
153
|
+
try {
|
|
154
|
+
const { hubVersion } = await stage({ ...o, dir, dockerfile: false })
|
|
155
|
+
const out = path.resolve(o.out ?? bootstrapTarballName(hubVersion, o.arch, o.opencodeVersion))
|
|
156
|
+
await exec(["tar", "-czf", out, "--no-xattrs", "-C", dir, "bin", "lib"])
|
|
157
|
+
return { out }
|
|
158
|
+
} finally {
|
|
159
|
+
rmSync(dir, { recursive: true, force: true })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface Args {
|
|
164
|
+
readonly command: "image" | "bootstrap" | "stage"
|
|
165
|
+
readonly arch: LinuxArch
|
|
166
|
+
readonly tag?: string
|
|
167
|
+
readonly out?: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const USAGE = "usage: build.ts image [--arch arm64|x64] [--tag NAME:TAG] | bootstrap [--arch arm64|x64] [--out FILE.tgz] | stage --out DIR [--arch arm64|x64]"
|
|
171
|
+
|
|
172
|
+
export function parseArgs(argv: readonly string[]): Args {
|
|
173
|
+
const [command, ...rest] = argv
|
|
174
|
+
if (command !== "image" && command !== "bootstrap" && command !== "stage") throw new Error(USAGE)
|
|
175
|
+
let arch: LinuxArch = linuxArch(process.arch)
|
|
176
|
+
let tag: string | undefined
|
|
177
|
+
let out: string | undefined
|
|
178
|
+
for (let i = 0; i < rest.length; i++) {
|
|
179
|
+
const flag = rest[i]!
|
|
180
|
+
const allowed = flag === "--arch" || (flag === "--tag" && command === "image") || (flag === "--out" && command !== "image")
|
|
181
|
+
if (!allowed) throw new Error(`unknown option ${flag}\n${USAGE}`)
|
|
182
|
+
const value = rest[++i]
|
|
183
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${flag} needs a value`)
|
|
184
|
+
if (flag === "--arch") arch = linuxArch(value)
|
|
185
|
+
else if (flag === "--tag") tag = value
|
|
186
|
+
else out = value
|
|
187
|
+
}
|
|
188
|
+
if (command === "stage" && !out) throw new Error(`stage needs --out DIR\n${USAGE}`)
|
|
189
|
+
return { command, arch, ...(tag ? { tag } : {}), ...(out ? { out } : {}) }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (import.meta.main) {
|
|
193
|
+
try {
|
|
194
|
+
const args = parseArgs(process.argv.slice(2))
|
|
195
|
+
const log = (line: string) => console.error(`[build] ${line}`)
|
|
196
|
+
if (args.command === "image") console.log((await buildImage({ arch: args.arch, log, ...(args.tag ? { tag: args.tag } : {}) })).tag)
|
|
197
|
+
else if (args.command === "bootstrap") console.log((await buildBootstrap({ arch: args.arch, log, ...(args.out ? { out: args.out } : {}) })).out)
|
|
198
|
+
else console.log((await stage({ arch: args.arch, log, dir: path.resolve(args.out!) })).dir)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
201
|
+
process.exit(1)
|
|
202
|
+
}
|
|
203
|
+
}
|