@huynle/hub 0.7.1 → 0.7.2
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 +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# OpenCode harness adapter (HBS-06)
|
|
2
|
+
|
|
3
|
+
The OpenCode `HarnessAdapter` for the bootstrap (contracts §3.10, §3.6.1, §3.12.1 and §5 in
|
|
4
|
+
`docs/bootstrap-contracts.md`). It only imports the frozen contracts and the transaction, discovery and preview
|
|
5
|
+
modules. Every capability is injected.
|
|
6
|
+
|
|
7
|
+
| File | Role |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `config.ts` | Reads an OpenCode 2 config (JSONC) and classifies the Hub plugin entry and the `hub` MCP server: absent, registered, drift, or a refusal. It computes the exact JSON-pointer sets for the lossless merge. |
|
|
10
|
+
| `plan.ts` | `planOpencode` and `createOpencodeAdapter`. The pinned `install-harness` comes first, then `select-harness-version`, `merge-config` (plugin), `register-mcp-server` and `verify-harness`. The planner is pure and deterministic. |
|
|
11
|
+
| `verify.ts` | Real readiness probes through a fixed-argv `ProcessRunner` (no shell). `locateOpencodeBinary` finds the binary that really runs. |
|
|
12
|
+
| `archive.ts` | A streaming unpacker for the upstream zip and tar.gz (`../archive-stream.ts`). It accepts exactly one regular file, `opencode`, streams it into the step's staging area and installs it as `bin/opencode` with mode 0755. `unpackOpencodeArchive` is the in-memory form for small fixtures. |
|
|
13
|
+
| `apply.ts` | The `HarnessSource` (the pinned URL only, https only, bounded by the pinned size, streamed into the staging area), the `register-mcp-server` and `verify-harness` executors, and `httpsDownload` (the body as chunks). |
|
|
14
|
+
| `index.ts` | `createOpencodeHarness`, the single entry point for the integrator. |
|
|
15
|
+
|
|
16
|
+
## Behaviour
|
|
17
|
+
|
|
18
|
+
- **Install.** OpenCode is installed only from the pin in hub's verified manifest for the host's exact target, into
|
|
19
|
+
`data:hub/harnesses/opencode/<version>`. That version is then made `current`.
|
|
20
|
+
- An OpenCode the user installed (native, brew or npm) is kept by default (`install: "if-missing"`).
|
|
21
|
+
- A hub-managed OpenCode at an older version gets the pinned version installed next to it. A newer one is never
|
|
22
|
+
downgraded by planning.
|
|
23
|
+
- Without a pin, a missing OpenCode is `unpinned_artifact`.
|
|
24
|
+
- A pin below 2.0.0 is refused, because the Hub plugin needs OpenCode 2.
|
|
25
|
+
- **Config.** OpenCode 2 uses `plugins: (string | {package, options?})[]` and `mcp.servers.<name>` (from
|
|
26
|
+
`@opencode/schema` 2.0.26, `config/plugin.js` and `mcp.js`). hubctl edits `config:opencode/opencode.jsonc` if it
|
|
27
|
+
exists, otherwise `opencode.json`. When neither exists, it creates `opencode.json`. The config is read only if
|
|
28
|
+
the snapshot lists it, and must still have the snapshot's digest, otherwise `preview_stale`.
|
|
29
|
+
- **Plugin.** If the Hub plugin is absent, it is appended to `/plugins`.
|
|
30
|
+
- These count as registered: an exact npm entry, an unpinned npm entry, or a custom source (a `github:huynle/hub`
|
|
31
|
+
spec or a checkout named `hub`).
|
|
32
|
+
- These are drift, fixed by one set on that entry (or on its `package` field only): an older pin, or the legacy
|
|
33
|
+
`@huynle/opencode-voice`.
|
|
34
|
+
- These are refused as `conflict`: a duplicate (including across `plugins` and the OpenCode 1 key `plugin`, or
|
|
35
|
+
across both files), or a pin newer than this hubctl.
|
|
36
|
+
- **MCP.** `mcp.servers.hub = {type: "local", command: [<authority>, "mcp"]}`.
|
|
37
|
+
- A `hub`/`hubctl mcp` server at another path (the plugin's launcher in the state dir, or a plugin cache) is
|
|
38
|
+
stale. Only its `command` is replaced; `environment`, `disabled` and other fields stay.
|
|
39
|
+
- These are refused: a foreign server named `hub`, or hub's MCP server under another name.
|
|
40
|
+
- **Authority.** The authority is `user-bin:hubctl` by default, or `user-bin:hub` (the same command surface). Any
|
|
41
|
+
other root or name (`state:hub/bin/…`, `data:…/node_modules/…`) is refused when the adapter is built.
|
|
42
|
+
- **Unparseable files.** Unparseable JSONC, or JSONC with duplicate keys, is `manual_edit_required`.
|
|
43
|
+
- **Symlinked configs.** The adapter plans normally. `planSetup` replaces both config actions with `symlinked-config`
|
|
44
|
+
manual edits that name the dotfiles file. Nothing is written through the link.
|
|
45
|
+
- **Readiness.** A planned or applied install is never readiness. The three required probes are:
|
|
46
|
+
- `version-exec`: `<binary> --version` must report OpenCode ≥ 2.0.0, and exactly the hub `current` version when
|
|
47
|
+
hub manages the binary. The binary is hub's `current` binary, otherwise what PATH resolves to. A container shim
|
|
48
|
+
is refused.
|
|
49
|
+
- `provider-auth`: `<binary> models` must list at least one `provider/model`. This sends no prompt and costs
|
|
50
|
+
nothing. A stronger `ProviderProbe` can be injected.
|
|
51
|
+
- `spoke-connected`: an injected `SpokeProbe`. Without one, the probe fails, so OpenCode is never ready by default.
|
|
52
|
+
|
|
53
|
+
On a cold start (the first use of a directory) OpenCode 2's background service can take longer than the CLI's ~5 s
|
|
54
|
+
to activate its plugins, and the CLI fails with HTTP 503 "Location plugins did not finish activating". The
|
|
55
|
+
`--version` and `models` probes retry once, after 1 s, on exactly that failure (`ACTIVATION_FAILURE`, read from the
|
|
56
|
+
child's output only to recognise it); any other failure is reported at once (HBS-12).
|
|
57
|
+
|
|
58
|
+
Details are built by the adapter, never copied from harness output. Anything that looks like a credential is
|
|
59
|
+
withheld. The child process gets the user's own environment, and nothing from it is recorded.
|
|
60
|
+
|
|
61
|
+
Contract v1.2 classifies them: `version-exec` is an integrity probe (its failure rolls the apply back);
|
|
62
|
+
`provider-auth` and `spoke-connected` wait on the user (`opencode auth login`, starting OpenCode), so by default
|
|
63
|
+
the `verify-harness` step commits OpenCode as installed, not ready (`requireReady: false`, set by `hubctl setup
|
|
64
|
+
apply` unless `--require-ready`). The executor's own default stays strict.
|
|
65
|
+
- **Rollback.** All rollback goes through the engine. The new version directory is removed, `current` is restored
|
|
66
|
+
from its backup, and the config is restored from its backup, except when the user changed it after hub wrote it.
|
|
67
|
+
Then it is kept and reported as a conflict (`bootstrap-adapter-opencode-apply.test.ts`).
|
|
68
|
+
|
|
69
|
+
## Upstream source of OpenCode 2.x (researched 2026-10-28)
|
|
70
|
+
|
|
71
|
+
| Channel | Finding | Source |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| GitHub releases | `sst/opencode` redirects to **`anomalyco/opencode`** (HTTP 301). The 2.x CLI is published there as tag **`v2.2.0`** (2026-10-27, by github-actions[bot], from `dev`). The same repository still ships a **1.x line** (`v1.19.5`, 2026-10-28). Because 1.19.5 is newer, GitHub's "latest release" is the 1.x line: never use `latest`, pin the exact tag. | https://github.com/anomalyco/opencode/releases/tag/v2.2.0, https://api.github.com/repos/anomalyco/opencode/releases |
|
|
74
|
+
| Release assets (v2.2.0) | CLI archives: `opencode-darwin-arm64.zip`, `opencode-darwin-x64.zip` (and `-baseline`), `opencode-linux-x64.tar.gz` (and `-baseline`, `-musl`, `-baseline-musl`), `opencode-linux-arm64.tar.gz` (and `-musl`), plus Windows zips, `opencode-desktop-*` apps and a `SHA256SUMS` file. | release page above |
|
|
75
|
+
| Archive layout | Checked with HTTP range reads, without downloading the archives. The zip has one entry, `opencode` (deflate, unix mode 0100755, 220 826 080 bytes uncompressed). The tar.gz has one ustar (GNU) entry, `opencode`, mode 0755. The uncompressed darwin-arm64 size equals the size of the locally installed `~/.opencode/bin/opencode`, which reports `opencode v2.2.0`. | range requests on the asset URLs |
|
|
76
|
+
| Authenticity | No signatures: no `.sig`, `.asc`, `.sigstore` or attestation assets. `SHA256SUMS` is unsigned. GitHub's API reports a per-asset `digest`. Both only help to cross-check the pinned digest (contract §5: OpenCode is vouched for only by hub's pin). | release API `assets[].digest`, `SHA256SUMS` |
|
|
77
|
+
| npm `opencode-ai` | dist-tag `latest` = **1.19.5**. No 2.x versions are published: this is the 1.x line. | https://registry.npmjs.org/opencode-ai |
|
|
78
|
+
| npm `@opencode/cli` | dist-tag `latest` = **2.2.0**, repository `anomalyco/opencode`. This is the 2.x CLI on npm under its new name. The SDK this repo uses (`@opencode/client`, `@opencode/plugin` ^2.0.26) comes from the same repository. | https://registry.npmjs.org/@opencode%2fcli |
|
|
79
|
+
|
|
80
|
+
**Recommendation.** Pin the **GitHub release archives of `anomalyco/opencode`, exact tag `v2.x.y`**, not npm:
|
|
81
|
+
|
|
82
|
+
- An npm install runs a package manager, with lifecycle scripts and platform-specific optional dependencies. That
|
|
83
|
+
is an installer, which `install-harness` must never carry (T21).
|
|
84
|
+
- The release archive is a single file, which hubctl checks against the pin and unpacks itself.
|
|
85
|
+
|
|
86
|
+
| Target | Asset | Format |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `darwin-arm64` | `opencode-darwin-arm64.zip` | zip (deflate) |
|
|
89
|
+
| `darwin-x64` | `opencode-darwin-x64-baseline.zip` | zip (deflate) |
|
|
90
|
+
| `linux-x64` (glibc) | `opencode-linux-x64-baseline.tar.gz` | tar.gz |
|
|
91
|
+
| `linux-arm64` (glibc) | `opencode-linux-arm64.tar.gz` | tar.gz |
|
|
92
|
+
|
|
93
|
+
**Use the `-baseline` assets on x64.** The non-baseline x64 builds require AVX2 and die with `SIGILL` (exit 132)
|
|
94
|
+
on CPUs without it: older Intel/AMD hosts and QEMU guests with the default `kvm64` CPU model. hubctl itself is built
|
|
95
|
+
for the same baseline (`bun-<os>-x64-baseline`, contracts §3.1), so a host that runs hubctl can run the harness it
|
|
96
|
+
installs. The baseline build runs on AVX2 CPUs too, at a small speed cost.
|
|
97
|
+
|
|
98
|
+
The musl and Windows assets are out of scope (decision Q7, §3.1). The URL is
|
|
99
|
+
`https://github.com/anomalyco/opencode/releases/download/v<version>/<asset>`. `evidence` is always `[]`.
|
|
100
|
+
|
|
101
|
+
### Proposed `harness-pins.json` entries (template: fill in only from your own download)
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"pin": {
|
|
106
|
+
"harness": "opencode",
|
|
107
|
+
"version": "2.2.0",
|
|
108
|
+
"target": "darwin-arm64",
|
|
109
|
+
"url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-darwin-arm64.zip",
|
|
110
|
+
"file": "opencode-darwin-arm64.zip",
|
|
111
|
+
"size": "<bytes you measured>",
|
|
112
|
+
"sha256": "sha256:<hex you computed>",
|
|
113
|
+
"evidence": [],
|
|
114
|
+
"minimumVersion": "2.0.0"
|
|
115
|
+
},
|
|
116
|
+
"review": { "by": "<your GitHub handle>", "on": "<YYYY-MM-DD>", "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] }
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Repeat the entry for `darwin-x64` (`opencode-darwin-x64-baseline.zip`), `linux-x64`
|
|
121
|
+
(`opencode-linux-x64-baseline.tar.gz`) and
|
|
122
|
+
`linux-arm64` (`opencode-linux-arm64.tar.gz`). `size` must be an integer. The placeholders above are deliberately
|
|
123
|
+
invalid, so an unfilled template fails `bootstrap-release-pins.test.ts`.
|
|
124
|
+
|
|
125
|
+
### Reviewer instructions
|
|
126
|
+
|
|
127
|
+
1. Open the release page for the tag. Confirm it is the 2.x line, published by the upstream workflow, and that the
|
|
128
|
+
asset name is exactly the target's asset in the table above.
|
|
129
|
+
2. Download the asset yourself, then measure and hash it:
|
|
130
|
+
`curl -fsSLo f "$URL" && wc -c < f && shasum -a 256 f`.
|
|
131
|
+
3. Cross-check against both upstream values: the asset's line in the release's `SHA256SUMS`, and `assets[].digest`
|
|
132
|
+
from `https://api.github.com/repos/anomalyco/opencode/releases/tags/v<version>`. If any value differs, stop.
|
|
133
|
+
4. List the archive and confirm it holds exactly one regular file, `opencode`: `unzip -Z1 f` or `tar -tzvf f`. The
|
|
134
|
+
adapter refuses anything else.
|
|
135
|
+
5. Keep `evidence: []`, because OpenCode publishes no signatures. Set `minimumVersion` to `2.0.0`, or higher to
|
|
136
|
+
raise the floor after a security release.
|
|
137
|
+
6. Fill in `review` and open the PR. Someone other than the author reviews it (`release/README.md`).
|
|
138
|
+
|
|
139
|
+
## Integration requirements (HBS-13)
|
|
140
|
+
|
|
141
|
+
HBS-13 made these edits (`packages/cli/src/setup/`, docs/bootstrap-contracts.md §7): A1 at the composition layer
|
|
142
|
+
(refusals become envelope errors), A2 (`setup/dispatch.ts`), the spoke probe (`GET /spoke/presence`), the launchers
|
|
143
|
+
(they prefer `~/.local/bin/hubctl`) and A4 (the shared fixtures use `plugins`). A3, A5 and A6 stay open (§7.6).
|
|
144
|
+
The original list:
|
|
145
|
+
|
|
146
|
+
- **Construction.** `packages/cli/src/cli.ts` (setup preview, apply and recover):
|
|
147
|
+
- Build `createOpencodeHarness({ probe: createNodeProbe(), manifest: <verified manifest>, pluginVersion?: <hubctl
|
|
148
|
+
version>, spoke: <SpokeProbe>, installed, floor, allowDowngrade })`.
|
|
149
|
+
- Register `.adapter` in the `AdapterRegistry` passed to **both** `previewSetup` and `revalidateApply`, with
|
|
150
|
+
identical options, so the plan digests match.
|
|
151
|
+
- **Refusals.** `planSetup` calls `adapter.plan()`, which **throws `OpencodePlanError`** (`code`: `conflict`,
|
|
152
|
+
`manual_edit_required`, `unpinned_artifact`, `preview_stale` or `invalid`). Catch it around
|
|
153
|
+
`previewSetup`/`revalidateApply` and print it as the envelope error with `exitFor(code)`. Amendment A1 would make
|
|
154
|
+
this unnecessary.
|
|
155
|
+
- **Executors.** The `ExecutorMap` holds one executor per kind. `register-mcp-server` and `verify-harness` must
|
|
156
|
+
dispatch on `action.harness` to each adapter's executor: `harness.executors(() => adapterContext)` for OpenCode.
|
|
157
|
+
`builtinExecutors({ harness })` takes one `HarnessSource`, so the integrator needs a source that dispatches on
|
|
158
|
+
`action.harness` (OpenCode: `harness.source`). See A2.
|
|
159
|
+
- **Adapter context.** `AdapterContext.env` must be the user's real `process.env`: the provider probe needs the user's
|
|
160
|
+
own provider configuration. `now: Date.now`.
|
|
161
|
+
- **Spoke probe.** The hub needs a machine-credential query that answers "is an OpenCode spoke of this machine
|
|
162
|
+
connected?". Today `GET /api/machines` (user auth) lists `processes` per machine. Without a `SpokeProbe`, OpenCode
|
|
163
|
+
is never ready, which is correct; since contract v1.2 it only blocks a strict (`--require-ready`) apply.
|
|
164
|
+
- **Authority.** The MCP server runs `user-bin:hubctl`, which this plan's `install-artifact` installs.
|
|
165
|
+
`src/spoke/launchers.ts` writes `hub`/`hubctl` launchers into the hub state dir that run the CLI bundle from the
|
|
166
|
+
plugin's install (cache) directory. They must not become what services or MCP clients depend on. Suggested
|
|
167
|
+
change: when a standalone `user-bin:hubctl` exists, the launchers exec it instead of the bundle.
|
|
168
|
+
- **No dependencies.** No new dependency and no `package.json` change. zlib, `child_process` and fetch come from the
|
|
169
|
+
runtime.
|
|
170
|
+
|
|
171
|
+
## Amendment requests (contracts are not modified here)
|
|
172
|
+
|
|
173
|
+
- **A1.** `HarnessAdapter.plan` returns only `Action[]`, so it cannot report a refusal or findings (drift, unpinned
|
|
174
|
+
plugin, stale MCP authority).
|
|
175
|
+
- Proposal: `plan` may return `{ actions, findings?, refusal?: {code, message} }`, or `planSetup` catches a
|
|
176
|
+
contract-defined `PlanRefusal` and returns it as a failed `Result`. Findings would appear in the `SetupPreview`
|
|
177
|
+
(a new optional `findings[]`).
|
|
178
|
+
- **A2.** `ExecutorMap` and `BuiltinDeps.harness` allow one executor and one source per kind, but three adapters
|
|
179
|
+
each supply `register-mcp-server`, `verify-harness` and a `HarnessSource`.
|
|
180
|
+
- Proposal: a `harnessDispatch` helper in `transaction/`, or `ExecutorMap` entries keyed `kind` →
|
|
181
|
+
`Record<HarnessID, ActionExecutor>` for harness-scoped kinds.
|
|
182
|
+
- **A3.** An unparseable JSONC config is only a bare `manual_edit_required` at plan time. The planner turns TOML,
|
|
183
|
+
not broken JSONC, into an `unmergeable-format` manual edit.
|
|
184
|
+
- Proposal: let an adapter mark a config action unmergeable, so the user gets the structured edit.
|
|
185
|
+
- **A4.** The shared fixtures use OpenCode 1 keys: `ACTIONS["merge-config"]` sets `/plugin/-`, and the discovery
|
|
186
|
+
fixture's `OPENCODE_CONFIG` has `plugin: []`. OpenCode 2 uses `plugins`.
|
|
187
|
+
- **A5.** `install-harness.previous` and `select-harness-version.previous` need a `sha256` of the previous version,
|
|
188
|
+
which discovery does not record, so the adapter omits `previous`.
|
|
189
|
+
- Proposal: discovery records the tree digest of a hub-managed version directory, or the history supplies it.
|
|
190
|
+
- **A6.** Nothing puts a hub-installed OpenCode on PATH, because `link-executable` is reserved for the hub ↔ hubctl
|
|
191
|
+
alias.
|
|
192
|
+
- Proposal: allow `link-executable` from `user-bin:opencode` to `harnessCurrentRef("opencode")/bin/opencode`, or
|
|
193
|
+
decide that services and launches always use the absolute path.
|
|
194
|
+
|
|
195
|
+
## Open questions
|
|
196
|
+
|
|
197
|
+
- Should the plugin default to being pinned to hubctl's own version? Today it is unpinned unless `pluginVersion` is
|
|
198
|
+
passed, and an existing unpinned entry is kept as the user's choice.
|
|
199
|
+
- `OPENCODE_CONFIG` / `OPENCODE_CONFIG_DIR` and project-level configs are not considered: discovery reports only
|
|
200
|
+
`config:opencode/opencode.json(c)`.
|
|
201
|
+
- Is `opencode models` strong enough as `provider-auth`, or should HBS-13 inject a one-token completion probe that
|
|
202
|
+
costs money?
|
|
203
|
+
- `opencode models` talks to the background service by default (the 2.x CLI also has `--standalone`). Should the
|
|
204
|
+
probe pin `--standalone`, so it tests exactly the binary that was installed?
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// What the OpenCode adapter gives the transactional applier (transaction/executor.ts): the HarnessSource for its
|
|
2
|
+
// pinned downloads and the executors for the two action kinds the built-ins leave to adapters.
|
|
3
|
+
//
|
|
4
|
+
// HarnessSource fetch: exactly the URL hub's verified manifest pins (checkHarnessPin), bounded by the
|
|
5
|
+
// pinned size, over https only, streamed into the step's staging area (never held whole);
|
|
6
|
+
// OpenCode publishes no upstream evidence, so only the pinned digest vouches for the bytes
|
|
7
|
+
// (installHarnessExecutor checks the staging area's digest before unpack is called).
|
|
8
|
+
// unpack: archive.ts (streams the binary into the staging area).
|
|
9
|
+
// register-mcp-server hubctl's `mcp` at the stable authority under mcp.servers.hub, decided from the file as it is
|
|
10
|
+
// at apply (config.ts) and spliced in by the lossless JSONC merge; only while the file is as
|
|
11
|
+
// previewed (precondition); a foreign "hub" server is a conflict. Journal class config: backed up,
|
|
12
|
+
// so a rollback restores it unless the user changed it since.
|
|
13
|
+
// verify-harness runs the adapter's real probes and classifies them (contract v1.2, harnessReadiness): an
|
|
14
|
+
// integrity failure (the binary is missing, does not run, is not the pinned version) fails
|
|
15
|
+
// the apply (harness_not_ready), which rolls the transaction back; with requireReady (the
|
|
16
|
+
// default) so does anything short of isHarnessReady; without it, a harness that only waits on
|
|
17
|
+
// the user (provider login, spoke) passes as "installed, not ready" (onReadiness).
|
|
18
|
+
import {
|
|
19
|
+
checkHarnessPin,
|
|
20
|
+
harnessReadiness,
|
|
21
|
+
isHarnessReady,
|
|
22
|
+
PROBE_MAX_AGE_MS,
|
|
23
|
+
type AdapterContext,
|
|
24
|
+
type HarnessAdapter,
|
|
25
|
+
type HarnessID,
|
|
26
|
+
type HarnessVerification,
|
|
27
|
+
type InstallHarness,
|
|
28
|
+
type PathRef,
|
|
29
|
+
type VerifiedManifest,
|
|
30
|
+
} from "../../contracts/index.ts"
|
|
31
|
+
import type { HarnessSource } from "../../transaction/builtin.ts"
|
|
32
|
+
import type { ActionExecutor, ExecutorMap, StepContext } from "../../transaction/executor.ts"
|
|
33
|
+
import { mergeConfigText } from "../../transaction/json-merge.ts"
|
|
34
|
+
import { txFail, txOk } from "../../transaction/result.ts"
|
|
35
|
+
import { responseChunks, type ByteSource } from "../../transaction/stream.ts"
|
|
36
|
+
import { passesAsPending, type ReadinessGate } from "../readiness.ts"
|
|
37
|
+
import { unpackOpencodeFile } from "./archive.ts"
|
|
38
|
+
import { mcpServerSets } from "./config.ts"
|
|
39
|
+
import { checkAuthority, DEFAULT_AUTHORITY } from "./plan.ts"
|
|
40
|
+
|
|
41
|
+
// ---------- downloads ----------
|
|
42
|
+
|
|
43
|
+
export interface DownloadLimits {
|
|
44
|
+
/** The pinned size: a longer body is refused while it streams. */
|
|
45
|
+
readonly maxBytes: number
|
|
46
|
+
readonly signal?: AbortSignal
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetches a body (no verification: the executor checks it against the pin): bytes, or chunks as they arrive (the
|
|
51
|
+
* source streams them into the staging area).
|
|
52
|
+
*/
|
|
53
|
+
export type Download = (url: string, limits: DownloadLimits) => Promise<ByteSource>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A Download over fetch: https only (every hop: the final URL too), refused once it exceeds the limit. The status
|
|
57
|
+
* and declared length are checked before it resolves; the body then streams, chunk by chunk, and fails as soon as
|
|
58
|
+
* it runs past the limit.
|
|
59
|
+
*/
|
|
60
|
+
export function httpsDownload(fetchImpl: (url: string, init?: RequestInit) => Promise<Response> = fetch): Download {
|
|
61
|
+
return async (url, limits) => {
|
|
62
|
+
if (!url.startsWith("https://")) throw new Error(`refusing to download ${url}: https only`)
|
|
63
|
+
const response = await fetchImpl(url, { redirect: "follow", ...(limits.signal ? { signal: limits.signal } : {}) })
|
|
64
|
+
if (response.url && !response.url.startsWith("https://")) throw new Error(`refusing ${url}: it redirected to a non-https location`)
|
|
65
|
+
if (!response.ok) throw new Error(`download of ${url} failed: HTTP ${response.status}`)
|
|
66
|
+
const declared = Number(response.headers.get("content-length") ?? "")
|
|
67
|
+
if (Number.isFinite(declared) && declared > limits.maxBytes) throw new Error(`${url} is larger than the pinned ${limits.maxBytes} bytes`)
|
|
68
|
+
return responseChunks(response, limits.maxBytes, () => new Error(`${url} is larger than the pinned ${limits.maxBytes} bytes`))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- the harness source ----------
|
|
73
|
+
|
|
74
|
+
export interface OpencodeSourceOptions {
|
|
75
|
+
readonly manifest: VerifiedManifest
|
|
76
|
+
readonly download: Download
|
|
77
|
+
/** The OpenCode hub has installed and made current (downgrade policy), from the integrator's install history. */
|
|
78
|
+
readonly installed?: () => { readonly version: string; readonly sha256: string } | undefined
|
|
79
|
+
/** The highest OpenCode floor this machine has seen. */
|
|
80
|
+
readonly floor?: () => string | undefined
|
|
81
|
+
/** Explicit --allow-downgrade. */
|
|
82
|
+
readonly allowDowngrade?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onlyOpencode = (harness: HarnessID) => {
|
|
86
|
+
if (harness !== "opencode") throw new Error(`the OpenCode harness source cannot install ${harness}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The HarnessSource for OpenCode artifacts (installHarnessExecutor verifies every byte against the pin). */
|
|
90
|
+
export function createOpencodeHarnessSource(options: OpencodeSourceOptions): HarnessSource {
|
|
91
|
+
return {
|
|
92
|
+
manifest: options.manifest,
|
|
93
|
+
async fetch(action: InstallHarness, staging) {
|
|
94
|
+
onlyOpencode(action.harness)
|
|
95
|
+
const pin = checkHarnessPin(options.manifest, action)
|
|
96
|
+
if (!pin.ok) throw new Error(`refusing to download ${action.source.url}: it is not the artifact hub ${options.manifest.release} pins (${pin.issues[0]!.message})`)
|
|
97
|
+
const body = await options.download(pin.value.url, { maxBytes: pin.value.size })
|
|
98
|
+
const file = await staging.write(action.source.file, body, { maxBytes: pin.value.size })
|
|
99
|
+
// OpenCode publishes no signatures (contract §5): there is no upstream evidence to attempt.
|
|
100
|
+
return { file, evidence: [] }
|
|
101
|
+
},
|
|
102
|
+
unpack: async (action, file, staging) =>
|
|
103
|
+
action.harness === "opencode" ? unpackOpencodeFile(action.source.file, staging.open(file), staging) : txFail("invalid", `the OpenCode harness source cannot unpack ${action.harness}`),
|
|
104
|
+
installed: (harness) => (harness === "opencode" ? options.installed?.() : undefined),
|
|
105
|
+
floor: (harness) => (harness === "opencode" ? options.floor?.() : undefined),
|
|
106
|
+
...(options.allowDowngrade ? { allowDowngrade: true } : {}),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- executors ----------
|
|
111
|
+
|
|
112
|
+
function preconditionHolds(ctx: StepContext, precondition: { readonly exists: boolean; readonly sha256?: string }): boolean {
|
|
113
|
+
const o = ctx.original
|
|
114
|
+
if (o.symlink) return false
|
|
115
|
+
return precondition.exists ? o.exists && o.sha256 === precondition.sha256 : !o.exists
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** register-mcp-server for OpenCode: hubctl's mcp at `authority` (default user-bin:hubctl). */
|
|
119
|
+
export function opencodeMcpExecutor(options: { readonly authority?: PathRef } = {}): ActionExecutor<"register-mcp-server"> {
|
|
120
|
+
const authorityRef = options.authority ?? DEFAULT_AUTHORITY
|
|
121
|
+
checkAuthority(authorityRef)
|
|
122
|
+
return {
|
|
123
|
+
kind: "register-mcp-server",
|
|
124
|
+
plan: (action) => ({ class: "config", path: action.path }),
|
|
125
|
+
check: async (action, ctx) => {
|
|
126
|
+
if (action.harness !== "opencode") return txFail("invalid", `this executor registers MCP servers with OpenCode, not ${action.harness}`)
|
|
127
|
+
if (action.name !== "hub") return txFail("invalid", `hubctl's MCP server is registered as "hub", not ${JSON.stringify(action.name)}`)
|
|
128
|
+
if (!preconditionHolds(ctx, action.precondition)) return txFail("conflict", `${ctx.target} changed since the preview; nothing was overwritten (preview again)`)
|
|
129
|
+
const authority = ctx.resolve(authorityRef, { finalMayBeSymlink: true })
|
|
130
|
+
return authority.ok ? txOk(undefined) : authority
|
|
131
|
+
},
|
|
132
|
+
change: async (_action, ctx) => {
|
|
133
|
+
const authority = ctx.resolve(authorityRef, { finalMayBeSymlink: true })
|
|
134
|
+
if (!authority.ok) return authority
|
|
135
|
+
const text = ctx.current.exists ? new TextDecoder().decode(ctx.fs.readFile(ctx.target!)) : undefined
|
|
136
|
+
const sets = mcpServerSets(text, authority.value)
|
|
137
|
+
if (!sets.ok) return sets
|
|
138
|
+
if (sets.value.length === 0) return txOk({ type: "none", reason: "hubctl's MCP server is already registered" })
|
|
139
|
+
const merged = mergeConfigText(text, ctx.target!.endsWith(".jsonc") ? "jsonc" : "json", sets.value)
|
|
140
|
+
if (!merged.ok) return merged
|
|
141
|
+
return txOk({ type: "file", bytes: new TextEncoder().encode(merged.value), mode: ctx.current.mode ?? 0o600, replace: "backup" })
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
/** verify-harness for OpenCode: ready (isHarnessReady), or only waiting on the user without requireReady; else it fails. */
|
|
148
|
+
export function opencodeVerifyExecutor(
|
|
149
|
+
options: {
|
|
150
|
+
readonly adapter: HarnessAdapter
|
|
151
|
+
readonly context: () => AdapterContext
|
|
152
|
+
/** Sees every verification (for the apply report); cannot change it. */
|
|
153
|
+
readonly onVerification?: (verification: HarnessVerification) => void
|
|
154
|
+
} & ReadinessGate,
|
|
155
|
+
): ActionExecutor<"verify-harness"> {
|
|
156
|
+
return {
|
|
157
|
+
kind: "verify-harness",
|
|
158
|
+
plan: () => ({ class: "external" }),
|
|
159
|
+
check: async (action) => (action.harness === options.adapter.id ? txOk(undefined) : txFail("invalid", `this executor verifies ${options.adapter.id}, not ${action.harness}`)),
|
|
160
|
+
change: async (action) =>
|
|
161
|
+
txOk({
|
|
162
|
+
type: "effect",
|
|
163
|
+
perform: async () => {
|
|
164
|
+
const context = options.context()
|
|
165
|
+
const verification = await options.adapter.verify(context)
|
|
166
|
+
options.onVerification?.(structuredClone(verification))
|
|
167
|
+
const now = context.now()
|
|
168
|
+
const readiness = harnessReadiness(verification, now, action.probes)
|
|
169
|
+
options.onReadiness?.(structuredClone(readiness))
|
|
170
|
+
// Every required probe (isHarnessReady) and every probe the action asks for: present, passing, fresh.
|
|
171
|
+
const missing = action.probes.filter((kind) => !passing(verification, kind, now))
|
|
172
|
+
if (verification.harness === "opencode" && isHarnessReady(verification, now) && missing.length === 0) return txOk(undefined)
|
|
173
|
+
if (verification.harness === "opencode" && passesAsPending(readiness, options)) return txOk(undefined)
|
|
174
|
+
const failed = verification.probes.filter((p) => !p.ok)
|
|
175
|
+
const why = [
|
|
176
|
+
...failed.map((p) => `${p.kind}${p.detail ? ` (${p.detail})` : ""}`),
|
|
177
|
+
...missing.filter((kind) => !failed.some((p) => p.kind === kind)).map((kind) => `${kind} (not run, or stale)`),
|
|
178
|
+
]
|
|
179
|
+
return txFail("harness_not_ready", `OpenCode is installed but not ready: ${why.join("; ") || "required probes missing"}`)
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The clock skew isHarnessReady tolerates (contracts/harness.ts). */
|
|
186
|
+
const CLOCK_SKEW_MS = 60_000
|
|
187
|
+
|
|
188
|
+
/** One probe kind present, passing and fresh (the same rule as isHarnessReady, for one kind). */
|
|
189
|
+
const passing = (v: HarnessVerification, kind: string, now: number) => {
|
|
190
|
+
const probes = v.probes.filter((p) => p.kind === kind)
|
|
191
|
+
return probes.length > 0 && probes.every((p) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The OpenCode executors to merge over builtinExecutors (see the integration notes in README.md). */
|
|
195
|
+
export function opencodeExecutors(options: {
|
|
196
|
+
readonly adapter: HarnessAdapter
|
|
197
|
+
readonly context: () => AdapterContext
|
|
198
|
+
readonly authority?: PathRef
|
|
199
|
+
readonly onVerification?: (verification: HarnessVerification) => void
|
|
200
|
+
} & ReadinessGate): Pick<ExecutorMap, "register-mcp-server" | "verify-harness"> {
|
|
201
|
+
return {
|
|
202
|
+
"register-mcp-server": opencodeMcpExecutor(options.authority ? { authority: options.authority } : {}),
|
|
203
|
+
"verify-harness": opencodeVerifyExecutor(options),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Unpacking a verified OpenCode release archive into the files of its immutable version directory (contract §3.6.1,
|
|
2
|
+
// data:hub/harnesses/opencode/<version>). Upstream (github.com/anomalyco/opencode releases) ships exactly one file,
|
|
3
|
+
// `opencode`, in a zip for macOS (opencode-darwin-<arch>.zip, deflate) and a gzipped tar for Linux
|
|
4
|
+
// (opencode-linux-<arch>.tar.gz). The executor has already checked the download against hub's pinned digest; this
|
|
5
|
+
// still refuses anything an archive could smuggle in: links, devices, names that climb out or are absolute, extra
|
|
6
|
+
// files (an installer script beside the binary), a duplicate, or no binary at all. The binary always becomes
|
|
7
|
+
// bin/opencode, mode 0755.
|
|
8
|
+
//
|
|
9
|
+
// Streaming (HBS-12: a 4 GB host was OOM-killed holding the archive and the ~350 MB binary several times over): the
|
|
10
|
+
// archive is read from the staging area in chunks (archive-stream.ts), every member is judged before its body is
|
|
11
|
+
// read, and the binary is streamed straight into a staging file, hashed as it is written. Nothing is held whole.
|
|
12
|
+
import { ArchiveError, readTarGz, zipMemberData, zipMembers } from "../archive-stream.ts"
|
|
13
|
+
import type { TreeFile } from "../../transaction/executor.ts"
|
|
14
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
15
|
+
import { bytesReader, memoryStaging, type ByteReader, type StagedFile, type StagingArea } from "../../transaction/stream.ts"
|
|
16
|
+
|
|
17
|
+
/** Where the binary goes inside the version directory. */
|
|
18
|
+
export const OPENCODE_BINARY_REL = "bin/opencode"
|
|
19
|
+
const BINARY_NAME = "opencode"
|
|
20
|
+
/** A generous bound on what one release may unpack to (the 2.2.0 binary is about 350 MB). */
|
|
21
|
+
export const MAX_UNPACKED_BYTES = 1024 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
const invalid = (file: string, why: string) => txFail("invalid", `${file}: ${why}; refusing to install it`)
|
|
24
|
+
|
|
25
|
+
/** An archive member name, normalized: "./x" and "x/" are fine; empty, absolute, "..", backslashes and NUL are not. */
|
|
26
|
+
function memberName(raw: string): string | undefined {
|
|
27
|
+
if (raw.startsWith("/") || raw.includes("\\") || raw.includes("\u0000")) return undefined
|
|
28
|
+
const parts = raw.split("/").filter((p) => p !== "" && p !== ".")
|
|
29
|
+
if (parts.some((p) => p === "..")) return undefined
|
|
30
|
+
return parts.join("/")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Judges one member: undefined to skip it (the archive's own root directory), "binary" to unpack it. */
|
|
34
|
+
function judge(raw: string, kind: "file" | "directory" | "other", hasBinary: boolean): "binary" | undefined {
|
|
35
|
+
const name = memberName(raw)
|
|
36
|
+
if (name === undefined) throw new ArchiveError(`the member ${JSON.stringify(raw)} has an unsafe name`)
|
|
37
|
+
if (kind === "directory" && name === "") return undefined
|
|
38
|
+
if (kind !== "file") throw new ArchiveError(`${JSON.stringify(raw)} is not a regular file (links and devices are never installed)`)
|
|
39
|
+
if (name !== BINARY_NAME) throw new ArchiveError(`unexpected member ${JSON.stringify(raw)} (an OpenCode release holds only the ${BINARY_NAME} binary)`)
|
|
40
|
+
if (hasBinary) throw new ArchiveError(`${BINARY_NAME} appears twice`)
|
|
41
|
+
return "binary"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tarKind = (type: string) => (type === "0" || type === "7" ? "file" : type === "5" ? "directory" : "other")
|
|
45
|
+
|
|
46
|
+
async function fromTarGz(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
|
|
47
|
+
let binary: StagedFile | undefined
|
|
48
|
+
// Symlinks (2), hard links (1), devices (3, 4), FIFOs (6), and pax/GNU headers that could rename a member (x, g,
|
|
49
|
+
// L, K) are all "other": refused.
|
|
50
|
+
await readTarGz(reader, { maxBytes }, async (member) => {
|
|
51
|
+
if (judge(member.header.name, tarKind(member.header.type), binary !== undefined) !== "binary") return
|
|
52
|
+
binary = await staging.write(BINARY_NAME, member.body(), { maxBytes: member.header.size })
|
|
53
|
+
})
|
|
54
|
+
return binary
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fromZip(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
|
|
58
|
+
// The whole directory is judged before any member is inflated.
|
|
59
|
+
const members = zipMembers(reader, { maxBytes })
|
|
60
|
+
let chosen: (typeof members)[number] | undefined
|
|
61
|
+
for (const m of members) if (judge(m.name, m.kind, chosen !== undefined) === "binary") chosen = m
|
|
62
|
+
if (!chosen) return undefined
|
|
63
|
+
return staging.write(BINARY_NAME, zipMemberData(reader, chosen), { maxBytes: chosen.size })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The install tree of a verified OpenCode archive named `file` (its format follows the upstream asset name), read
|
|
68
|
+
* from `reader`; the binary is streamed into `staging`. `maxBytes` (default MAX_UNPACKED_BYTES) bounds what it
|
|
69
|
+
* inflates to.
|
|
70
|
+
*/
|
|
71
|
+
export async function unpackOpencodeFile(file: string, reader: ByteReader, staging: StagingArea, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
|
|
72
|
+
const maxBytes = options.maxBytes ?? MAX_UNPACKED_BYTES
|
|
73
|
+
const zip = file.endsWith(".zip")
|
|
74
|
+
if (!zip && !file.endsWith(".tar.gz") && !file.endsWith(".tgz")) return invalid(file, "not an OpenCode CLI archive (expected .zip or .tar.gz)")
|
|
75
|
+
let binary: StagedFile | undefined
|
|
76
|
+
try {
|
|
77
|
+
binary = zip ? await fromZip(reader, staging, maxBytes) : await fromTarGz(reader, staging, maxBytes)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof ArchiveError) return invalid(file, error.message)
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
if (!binary) return invalid(file, `no ${BINARY_NAME} binary inside`)
|
|
83
|
+
return txOk([{ rel: OPENCODE_BINARY_REL, staged: binary, mode: 0o755 }])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** unpackOpencodeFile over bytes in memory, the tree as bytes (small archives: tests, fixtures). */
|
|
87
|
+
export async function unpackOpencodeArchive(file: string, bytes: Uint8Array, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
|
|
88
|
+
const staging = memoryStaging()
|
|
89
|
+
const r = await unpackOpencodeFile(file, bytesReader(bytes), staging, options)
|
|
90
|
+
return r.ok ? txOk(r.value.map((f) => ("staged" in f ? { rel: f.rel, mode: f.mode, bytes: staging.bytes(f.staged) } : f))) : r
|
|
91
|
+
}
|