@matuto-ai/opencode-broker-connector 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +61 -43
  3. package/bin/doctor.mjs +5 -0
  4. package/docs/candidate-verification.md +25 -0
  5. package/docs/integration.md +8 -6
  6. package/docs/legacy-connection.md +77 -0
  7. package/docs/message-lifecycle.md +24 -0
  8. package/docs/native-automation.md +155 -0
  9. package/docs/native-v2.md +121 -0
  10. package/docs/owned-run.md +4 -2
  11. package/docs/publishing.md +5 -5
  12. package/docs/reference-snapshot.sha256 +5 -5
  13. package/docs/sandbox-persistence.md +43 -0
  14. package/docs/verification.md +62 -0
  15. package/package.json +15 -4
  16. package/plugin/index.mjs +14 -2
  17. package/plugin/tui.mjs +17 -0
  18. package/plugin/v2.mjs +18 -0
  19. package/src/approval-helper.mjs +11 -0
  20. package/src/capabilities.mjs +13 -0
  21. package/src/config.mjs +5 -2
  22. package/src/cooperative-inbox.mjs +13 -8
  23. package/src/enrollment.mjs +52 -0
  24. package/src/installation.mjs +123 -0
  25. package/src/message-format.mjs +26 -0
  26. package/src/native-context.mjs +57 -0
  27. package/src/native-delivery.mjs +151 -0
  28. package/src/native-plugin.mjs +135 -13
  29. package/src/opencode-adapter.mjs +31 -15
  30. package/src/pending-resume.mjs +38 -0
  31. package/src/reference-pin.mjs +6 -6
  32. package/src/reference.mjs +1 -1
  33. package/src/setup-ui.mjs +100 -0
  34. package/src/tool-catalog.mjs +7 -2
  35. package/src/tool-scope.mjs +26 -0
  36. package/src/v2-delivery.mjs +149 -0
  37. package/src/v2-inbox.mjs +33 -0
  38. package/src/v2-lock.mjs +17 -0
  39. package/src/v2-plugin.mjs +192 -0
  40. package/src/v2-setup.mjs +133 -0
  41. package/v2/LICENSE +21 -0
  42. package/v2/README.md +17 -0
  43. package/v2/index.mjs +2 -0
  44. package/v2/package-lock.json +420 -0
  45. package/v2/package.json +13 -0
  46. package/vendor/broker/ORIGIN.md +11 -4
  47. package/vendor/broker/reference/client.mjs +90 -15
  48. package/vendor/broker/reference/inbox.mjs +55 -10
  49. package/vendor/broker/reference/mcp-stdio.mjs +5 -1
  50. package/vendor/broker/reference/tools.mjs +38 -14
  51. package/vendor/broker/src/protocol.mjs +19 -0
  52. package/vendor/enrollment/LICENSE +21 -0
  53. package/vendor/enrollment/ORIGIN.md +20 -0
  54. package/vendor/enrollment/approval.mjs +181 -0
  55. package/vendor/enrollment/enrollment.mjs +137 -0
@@ -0,0 +1,121 @@
1
+ # Native OpenCode V2 candidate (2.2.5 only)
2
+
3
+ This is an **isolated candidate**, not an upgrade/migration of classic OpenCode
4
+ 1.19.x, a release, or a claim that classic automatic delivery is fixed. The
5
+ existing default export and classic capability guard are unchanged.
6
+
7
+ ## Native package installation
8
+
9
+ On an already installed **OpenCode V2 2.2.5**, install the server-only candidate
10
+ with the native plugin manager (the official CLI package names its executable
11
+ `opencode2`):
12
+
13
+ ```sh
14
+ opencode2 plugin add 'github:matuto-ai/opencode-broker-connector#feat/v2-native-automatic::path:v2'
15
+ ```
16
+
17
+ The candidate branch can change; use the full published commit from the PR
18
+ handoff instead of `feat/v2-native-automatic` when pinning the installer itself.
19
+ OpenCode 2.2.5 supports full-commit Git `::path:` package selectors. Its plugin
20
+ manager persists installation; no hand-written host config or connection file is
21
+ needed. This command installs only the plugin, not OpenCode or a model provider.
22
+
23
+ The nested package is self-contained: it declares the exact public runtime
24
+ `81bab6924d0f7193b8b3f32318ea4cbc1d23a17f` as its dependency and imports that
25
+ package's explicit `./v2` export. It does not reach outside its package, load the
26
+ classic root export/TUI, or create a dependency cycle. The runtime SHA includes
27
+ all reviewed delivery, cancellation, approval-lifetime and archived-owner fixes.
28
+ No release or host upgrade is implied by installing this draft candidate.
29
+
30
+ After installation, ask the native agent to use `broker_setup` with only
31
+ `broker_url: "https://your-broker.example"`. It returns the exact public request ID
32
+ and broker admin URL. Human admin approval remains required; the plugin polls the
33
+ same durable request with GET-only continuation and connects automatically after
34
+ approval. `broker_setup_status` is read-only; `broker_cancel_setup` pauses only the
35
+ local pending flow. Startup never creates enrollment requests. No token or private
36
+ file handoff is required. Existing approved installation selection is reused.
37
+
38
+ An explicit private `connectionFile` option or `PSST_CONFIG_FILE` override remains
39
+ available for compatibility/fixtures; it disables URL setup to avoid secretly
40
+ switching an explicitly configured connection. It does not rewrite host config, change permissions, select
41
+ an agent/model, install/upgrade OpenCode, or migrate classic sessions. The `./v2`
42
+ JavaScript export is also available. A local development `plugins` entry must be
43
+ a **directory**, not a single file; normal installation uses the command above. New native root sessions connect automatically;
44
+ known exact session bindings restore after plugin reload. Subagents do not share
45
+ the root's inbox.
46
+
47
+ **Evidence boundary:** isolated package installation resolves the pinned public
48
+ runtime and imports the native plugin with no repository sibling files, without
49
+ executing lifecycle scripts. Exact 2.2.5 source tests cover full-SHA Git subdirectory
50
+ installation with dependencies. The earlier real-host fixture loaded the same
51
+ runtime through local-directory configuration. The new plugin-manager command
52
+ has **not yet been executed against the owner's installed V2 host** because that
53
+ sandbox is not accessible from this executor. Do not equate dependency-resolution
54
+ proof with a fresh native-host or authenticated-model acceptance test.
55
+
56
+ ## Delivery and recovery contract
57
+
58
+ - Public V2 APIs only: `session.get`, `synthetic`, `context`, session prompt hook,
59
+ event subscription, storage, tool transform. Host version is explicitly gated.
60
+ - Stable private binding: server plugin-storage anchor, native session ID,
61
+ creation time, project, location/workspace, broker installation; exclusive
62
+ SQLite ownership; persisted random broker identity. Partial/foreign state is
63
+ never automatically discarded, migrated or taken over.
64
+ - Compact sender/message envelope; native `synthetic` role and machine-source
65
+ metadata. No human prompt, repeated policy paragraph, visible authority field,
66
+ current agent/model override, user draft edit or steering injection.
67
+ - Commit exact payload, native ID and local injection ID before the single
68
+ `synthetic({delivery:"queue",resume:true})` call. Require exact native session,
69
+ ID, type, delivery and payload, not merely HTTP success.
70
+ - A receipt proves **durable native admission**, not model reading, task success,
71
+ or authorization. At most one pending native item; the next waits for exact
72
+ prior promotion in native context.
73
+ - Native cancellation removes the host's pending idempotency lookup entry.
74
+ Therefore **no automatic synthetic retry**, even with the same ID. Lost
75
+ responses reconcile read-only against exact promoted synthetic context.
76
+ Pending-but-unproven, cancelled, compacted-away or mismatched receipts remain
77
+ pinned/blocked, never guessed, deleted or reinjected.
78
+ - Interrupted/failed host sessions and cancel/revert events pause delivery.
79
+ Only subsequent native execution can reopen the event pause. Session move,
80
+ archive, delete or plugin unload fences the owner. Unload is resumable and does
81
+ not terminally close a surviving broker identity; native disappearance is not
82
+ treated as permission to silently allocate a replacement. A queued item cancelled by
83
+ the user is not recreated.
84
+ - No cooperative receive/ack tools are registered in V2. The eight outgoing/
85
+ status/metadata lifecycle tools use the shared reviewed SDK. Native tool-call
86
+ identity scopes private durable send/retry keys. Send/resend additionally require
87
+ the actual matching native assistant tool block creation time, so an aged replay
88
+ cannot mint a new operation after journal GC. Missing/compacted proof fails closed.
89
+
90
+ ## Verification
91
+
92
+ ```sh
93
+ node --test test/v2-delivery.test.mjs test/v2-plugin.test.mjs test/v2-setup.test.mjs
94
+ OPENCODE_V2_BINARY=/path/to/official/opencode2 node --test test/v2-host.test.mjs
95
+ # Optional native installer proof: existing CLI, disposable HOME/XDG only.
96
+ OPENCODE_V2_BINARY=/path/to/official/opencode2 \
97
+ PSST_V2_PACKAGE_SPEC='github:matuto-ai/opencode-broker-connector#FULL_COMMIT::path:v2' \
98
+ node --test test/v2-installer.test.mjs
99
+ npm test
100
+ npm run check
101
+ npm run pack:check
102
+ ```
103
+
104
+ The real-host test packs/extracts the candidate, loads its nested V2 directory
105
+ through the native plugin manager in an isolated HOME/XDG tree with random local
106
+ HTTP Basic credentials, connects a real local TLS broker, sends a real envelope,
107
+ and verifies automatic native synthetic admission and Plan→Build preservation.
108
+ With `PSST_APPROVAL_BROKER_DIR` pointing to the approval-enabled broker checkout,
109
+ a second real-host test invokes the registered native setup executor through a
110
+ fixture command/public tool API, approves the real TLS request in the disposable
111
+ admin fixture, and verifies automatic connection/delivery. This is actual native
112
+ plugin/tool integration, not a model-issued tool-call proof.
113
+ The selected fixture provider deliberately does not exist: **no user credentials,
114
+ model API or authenticated LLM response is used or proved**. Unit mocks separately
115
+ exercise uncertain writes, exact receipts, cancellation, restart, owner isolation,
116
+ selection changes, FIFO bounds and stalled requests.
117
+
118
+ Official contracts: [plugin loading](https://opencode.ai/v2/docs/plugins/),
119
+ [server plugins](https://opencode.ai/v2/docs/build/plugins/server),
120
+ [native synthetic admission](https://github.com/anomalyco/opencode/blob/v2.2.5/packages/core/src/session/session.ts#L280-L320),
121
+ [pending/reconciliation semantics](https://github.com/anomalyco/opencode/blob/v2.2.5/packages/core/src/session/inbox.ts).
package/docs/owned-run.md CHANGED
@@ -1,13 +1,15 @@
1
+ > Candidate update: production requires automatic delivery and is currently blocked on the classic host. Manual pull/owned-run flows below are diagnostic/test-only. See [current lifecycle contract](message-lifecycle.md) for hidden operation keys and new APIs. Historical fixture pins are separate from the runtime candidate.
2
+
1
3
  # Owned fresh OpenCode run (candidate)
2
4
 
3
- This mode launches **one new headless `opencode run`**, rather than attaching to an existing TUI, server session, transcript or resumed conversation. It offers the same five cooperative broker tools as the adapter factory. It does not interrupt a task, inject a prompt automatically, turn another agent's text into approval, or mark a native tool result consumed.
5
+ This mode launches **one new headless `opencode run`**, rather than attaching to an existing TUI, server session, transcript or resumed conversation. It offers the same nine diagnostic broker tools as the adapter factory. It does not interrupt a task, inject a prompt automatically, turn another agent's text into approval, or mark a native tool result consumed.
4
6
 
5
7
  ## Supported pinned prerequisites
6
8
 
7
9
  - Linux x64 with `/proc` available; launcher Node **24.19.0**.
8
10
  - Official `opencode-linux-x64` **1.19.4** executable. Its SHA256 must be `538fcb1b6408c8d4608822acee19f7ff5b3a3eb3383c0633c4f06170857312a7`. Other builds/platforms are deliberately refused, not assumed compatible.
9
11
  - `npm ci --ignore-scripts` in this repository installs the locked official `@opencode-ai/plugin` **1.19.4** helper. The external native plugin requires this local dependency; OpenCode's separate config-directory dependency is not a substitute.
10
- - A local broker reference checkout at **`ba2219cd87a1feac23e01b23d2c2b3d3bdd65359`**. The loader verifies its runtime file hashes before importing it.
12
+ - A local broker reference checkout at **`2de9cc3758b9fc593f5afca4fc501fd013d416ec`**. The loader verifies its runtime file hashes before importing it.
11
13
  - An already configured, trusted HTTPS broker and private token file. This launcher does not create credentials, expose a server, alter TLS verification or configure a model provider.
12
14
 
13
15
  The pinned OpenCode executable embeds Bun **1.4.4**. Its `node:sqlite` compatibility was exercised with the actual shared Inbox; no separate Bun installation or global Node change is required.
@@ -5,21 +5,21 @@ Package name: `@matuto-ai/opencode-broker-connector`. The npm workflow follows t
5
5
  1. Review and merge the feature PR.
6
6
  2. Bootstrap the package under an npm account authorized for this package if it does not exist yet. Account creation, credentials and first publication are operator actions, not performed by the connector.
7
7
  3. Configure npm's GitHub Actions trusted publisher for owner `matuto-ai`, repository `opencode-broker-connector`, workflow `npm-publish.yml`. If the repository moves, update package metadata and trusted-publisher settings together.
8
- 4. Create a GitHub release whose tag exactly matches `v` + package.json version (next `v0.1.1`).
8
+ 4. Create a GitHub release whose tag exactly matches `v` + package.json version (next `v0.4.0`).
9
9
  5. The action runs syntax, tests including pinned actual CLI, package inspection, then `npm publish --access public --provenance` using OIDC. No long-lived npm token is stored in the workflow.
10
10
  6. Confirm npm has the exact version before recommending npm installation. Failed publishing is not repaired by silently changing a version or recreating a tag.
11
11
 
12
12
  `id-token: write` is limited to the release workflow. This repository does not configure a trusted publisher, generate credentials, publish, merge or deploy automatically on ordinary pushes/PRs. Those events run read-only CI checks only.
13
13
 
14
- Before npm publication, the local-file installation in README works directly from `main` after the naming PR is reviewed and merged. No unpublished dependency is required.
14
+ Before npm publication, the local-file installation in README works directly from `main` after the release PR is reviewed and merged. No unpublished dependency is required.
15
15
 
16
- ## Patch release
16
+ ## Minor release
17
17
 
18
- This PR prepares `0.1.1`; the intended GitHub release tag is `v0.1.1`.
18
+ This PR prepares `0.4.0`; the intended GitHub release tag is `v0.4.0`.
19
19
  After review and merge, the maintainer can publish that release from the merged
20
20
  commit to exercise npm Trusted Publishing. This code change does not itself
21
21
  create a tag, release, or npm publication. Verify the workflow succeeds and the
22
22
  scoped npm version exists before recommending it for installation.
23
23
 
24
- Existing `0.1.0` npm packages and old GitHub tags remain untouched. Do not reuse
24
+ Existing `0.1.x` npm packages and old GitHub tags remain untouched. Do not reuse
25
25
  or move an old release tag; the new tag must exactly match the package version.
@@ -1,10 +1,10 @@
1
- 93f75786eab29429ace024974e0f61d6ad46d2c5fe065dae387e54fd2373b167 reference/inbox.mjs
2
- f661b45acba106be03d97dacba3cd90ac18b09e7ff9eb93b995d5036d9d1c785 reference/client.mjs
1
+ fbf0fa6096996cb9bc3f928e55f3eca496420880a03460e993dda3417b32ad88 reference/inbox.mjs
2
+ c4c313e937d1003f28928a672072b926022aa0e9fd65825bc5db1078eb90afa9 reference/client.mjs
3
3
  8733076b73a42ab8f12fe20ba9908acdd754d89734ffd335e84b49aee9d737ca reference/runtime.mjs
4
- 5e6355cef1bec56170dca14b93142a7da8c102f411c5dbf437d84acda7351acf reference/tools.mjs
5
- 10a93788af426ce898c398e38dd41ac77b6e0129a7697bdc36c8d4f6d6e20138 reference/mcp-stdio.mjs
4
+ 5d3b16212a048912395d74b95a8b590d278b8b0915bb0bd7a0c3d80daf353839 reference/tools.mjs
5
+ f34b0cdcda9c2d607815e4ef99f1ba80fbd8936ef8c2893a858b6d408bc0ff09 reference/mcp-stdio.mjs
6
6
  e33231d94f514b5a1b59fdd4184f5881dd3712bde725a1cf53352c26293452ac reference/errors.mjs
7
- f3ff24b4429dddc8a9c10513ab72dbc613c91244591132458c7866a812d6316b src/protocol.mjs
7
+ 2d88829971643d47430d6696d0a6ac61589ea4f9001c860c5c196ecc5ac21a6f src/protocol.mjs
8
8
  575ac7406ed9b8698d97ecdc2f093a99455fdd581f375c4611c844d4618095c8 src/store.mjs
9
9
  69d5130ce90e045d0cd66facdc4380ef03f50895c53997fe9a664814fcca0e7b src/server.mjs
10
10
  8d85ecd3c02142de87de34425b71daa35b7317bbe3cfd19865c43e7bb57e3785 src/auth.mjs
@@ -0,0 +1,43 @@
1
+ # Sandbox bootstrap contract
2
+
3
+ Historical source inspected: `ai-agents-sandbox` snapshot
4
+ `a7f63e8b98d37c030df6ca095d2e75960111e37c`,
5
+ `agents/opencode-native/config.sh:get_project_mounts/get_project_env`.
6
+ This is design evidence, **not verification of current live mounts**.
7
+
8
+ The existing launcher maps `<project>/opencode-zen/{config,data,cache,state}`
9
+ (or the separate `opencode-go` profile) to:
10
+
11
+ | Leaf | Container path |
12
+ | --- | --- |
13
+ | config | `/home/node/.config/opencode` |
14
+ | data | `/home/node/.local/share/opencode` |
15
+ | cache | `/home/node/.cache/opencode` |
16
+ | state | `/home/node/.local/state/opencode` |
17
+
18
+ Use `PSST_OPENCODE_CONFIG_DIR=/home/node/.config/opencode/psst` in that bootstrap
19
+ so the whole installation uses the **existing config mount**, including Go's
20
+ managed `XDG_CONFIG_HOME=/etc` override. The `psst` subdirectory must be owned by
21
+ the sandbox user, mode 0700; config/key files are 0600. Recreated containers must
22
+ mount it at the same absolute path. Different project/profile mounts intentionally
23
+ remain separate authorizations unless bootstrap explicitly shares this private
24
+ installation directory. Sharing it never means sharing native session IDs/inboxes.
25
+ Do not mount the ephemeral `/tmp/psst-opencode-<uid>` directory across containers.
26
+
27
+ The archived launcher also sets `OPENCODE_PURE=1` and disables project config.
28
+ Official 1.19.4 explicitly skips external TUI plugins in pure mode. Therefore
29
+ that historical bootstrap needs an intentional native plugin allowlist/install
30
+ integration before this connector can load; merely saving files cannot override
31
+ that policy. Preserve managed model/provider, permission and credential policy.
32
+ This connector does not remove pure mode or edit the sandbox launcher itself.
33
+ The current owner environment remains an acceptance gate until its effective
34
+ bootstrap is inspected and the intended plugin installation is permitted.
35
+
36
+ ## Read-only capability diagnosis
37
+
38
+ The packaged `psst-opencode` doctor reports `OPENCODE_PURE` only as observed in
39
+ its own process environment; it never disables it or claims to inspect every
40
+ managed source. A host that skips the plugin cannot run the plugin's diagnostic
41
+ hook. Ask the administrator to review the actual approved launcher source.
42
+ Even with plugins enabled, the pinned classic host remains `automatic_blocked`;
43
+ transport presence/manual receive must not be reported as production readiness.
@@ -1,3 +1,5 @@
1
+ > Candidate update: production requires automatic delivery and is currently blocked on the classic host. Manual pull/owned-run flows below are diagnostic/test-only. See [current lifecycle contract](message-lifecycle.md) for hidden operation keys and new APIs. Historical fixture pins are separate from the runtime candidate.
2
+
1
3
  # Native plugin verification (October 24, 2026)
2
4
 
3
5
  The current default package adds native session-keyed cooperative tools and a self-contained pinned reference runtime. CI runs the full suite with the integrity-verified official OpenCode 1.19.4 Linux x64 fixture (embedded Bun 1.4.4).
@@ -110,3 +112,63 @@ arbitration; per-CLI Podman containers or two-host LAN operation.
110
112
  `broker_receive` remains a non-consuming FIFO **peek**. A native tool return or
111
113
  model read does not advance the inbox. See [owned-run instructions](owned-run.md)
112
114
  for operator requirements and failure/unknown-outcome behavior.
115
+
116
+ ## Explicit connection regression coverage
117
+
118
+ The packed production plugin is loaded by official OpenCode 1.19.4 with no `PSST_CONFIG_FILE`. A native session command connects using a private file, discovery sees the exact new session, and native disconnect prevents subsequent message injection while the host remains available. The fixture uses a deterministic local model provider and a trusted loopback TLS broker; no paid provider, user credential, desktop/TUI automation, or production installation is exercised. Unit tests cover disconnected startup, session-isolated status, disconnect/no re-registration, and deleted-session fencing. Automatic model delivery and pairing issuance are separate changes, not claims of this connection-only step.
119
+
120
+ ## Experimental native delivery (explicit opt-in only)
121
+
122
+ The second actual-host regression loads the npm-packed production plugin into official OpenCode 1.19.4/Bun, connects with no startup broker environment, sends two FIFO messages through verified TLS, with the fixed-session-snapshot experimental opt-in, preserves Plan mode and the selected model, and observes both exact synthetic native messages and the second payload in the deterministic local model's input. It never calls `broker_receive`, never accesses paid providers, and verifies disconnect prevents later injection. This proves real native integration, not a desktop/TUI end-to-end installation or production-broker test. Unit regressions cover missing/wrong readback, single in-flight FIFO, busy/reverted/human boundaries, compaction during submit, abort during receipt lookup, lost submit responses, delayed readback reconciliation and same-session journal reopen without another native submission. Native source basis: `packages/opencode/src/session/prompt.ts`, `effect/runner.ts`, and `server/routes/instance/httpapi/handlers/session.ts` at official tag v1.19.4.
123
+
124
+ ## Scoped pairing boundary
125
+
126
+ A further actual OpenCode smoke loads the packed plugin, passes only a private pairing-file path to the native command, and exercises the unmodified shared enrollment helper over system-trusted HTTPS. A synthetic endpoint implementing the frozen broker contract records a hash-only credential and authorizes it in the real transport fixture; automatic peer messages then reach the deterministic local model. This checks native/helper/credential integration, not the production broker's operator issuance implementation (covered by upstream broker tests). Unit tests additionally cover marked bundle receipt deletion/type/origin/token mismatches, private permissions, per-native-session attempt separation, expiry shutdown, revoked authorization shutdown and no late connect after disconnect.
127
+
128
+ The default connector reports automatic readiness blocked; manual receive requires an explicit diagnostic request. Additional pinned-host coverage proves that the official v2 session identity is visible while its context/history remain empty for a classic CLI conversation with real messages. The connector does not mix these engines. Unknown agent/model state blocks the experiment and observed changes disconnect it. No test establishes atomic admission against simultaneous external native selection edits.
129
+
130
+ ## URL-only approval and persistent native configuration (0.3.0 candidate)
131
+
132
+ The new gates use official **OpenCode 1.19.4**, not a mocked host API:
133
+
134
+ - `native-tui.test.mjs`: loads the production TUI entrypoint and sends real PTY
135
+ keyboard input through the supported Ctrl+Alt+P setup binding. Invalid URLs
136
+ remain unselected; the single-field URL form durably saves its request before
137
+ attempted HTTPS transport. This is a disposable test terminal, not a user's TUI.
138
+ - `native-tui-approval.test.mjs`: loads the production TUI and server entrypoints;
139
+ a transparent fixture creates/navigates to an empty existing classic session
140
+ using the supported SDK. Real keyboard setup requests actual broker enrollment;
141
+ authenticated admin form/CSRF approval commits config; the private native-context
142
+ hint connects that already-idle session. Its native message history remains
143
+ empty, proving setup/attachment did not manufacture a model turn.
144
+ - `native-approval-integration.test.mjs`: real broker approval, 30 distinct native
145
+ sessions in the packed production plugin, process restart with unchanged
146
+ installation authorization and new session addresses, then real admin revocation.
147
+ - `native-installation.test.mjs`: preserves legacy installation compatibility
148
+ across the same native 30-session/restart path.
149
+ - `installation.test.mjs`, `setup-ui.test.mjs`, and `native-context.test.mjs` cover
150
+ selection CAS, cancellation/late results, exact request binding, replacement,
151
+ transient backoff, GET-only startup resume, terminal denial/revocation/expiry,
152
+ corrupt/private files, distinct container runtime roots, stale incarnation/owner
153
+ hints and route changes. These unit fixtures are not real container orchestration.
154
+
155
+ CI checks out the immutable broker dependency specified in `.github/workflows/ci.yml`
156
+ and supplies `PSST_APPROVAL_BROKER_DIR`; without it the real-broker native gates
157
+ explicitly skip. Locally point that variable at that exact reviewed broker tree.
158
+ No provider credentials, live broker, real user installation or release is used.
159
+ The helper hash/source anchor is recorded in `vendor/enrollment/ORIGIN.md`.
160
+
161
+ **Not established by these gates:** paid/authenticated model provider behavior,
162
+ classic atomic automatic model receive (the fresh native regression demonstrates
163
+ why it is unsafe), or unchanged historical sandbox pure-mode policy permitting
164
+ plugins. Native process recreation is not itself Docker/Podman mount evidence;
165
+ real orchestration gates are reported separately rather than inferred from unit
166
+ fixtures. See `docs/sandbox-persistence.md` for the exact audited bootstrap boundary.
167
+
168
+ The separate `native-containers` CI matrix runs the approved 30-session/revocation
169
+ native test with each official OpenCode server inside a fresh Docker or rootless
170
+ Podman container, using the pinned Node image and integrity-checked official CLI.
171
+ On recreation the test uses a new HOME/data/cache/state; only the private selected
172
+ installation plus read-only installed code/bootstrap fixtures remain available.
173
+ The runtime `/tmp` hints are not mounted. A submitted workflow is not a pass:
174
+ the exact PR head's Docker and Podman jobs are the authoritative execution evidence.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@matuto-ai/opencode-broker-connector",
3
- "version": "0.1.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.19.0 <25"
7
7
  },
8
8
  "scripts": {
9
9
  "test": "node scripts/test.mjs",
10
- "check": "find src bin plugin examples scripts test vendor \\( -name \"*.mjs\" -o -name \"*.ts\" \\) -not -path \"*/node_modules/*\" -print0 | xargs -0 -r -n1 node --check",
10
+ "check": "find src bin plugin v2 examples scripts test vendor \\( -name \"*.mjs\" -o -name \"*.ts\" \\) -not -path \"*/node_modules/*\" -print0 | xargs -0 -r -n1 node --check",
11
11
  "pack:check": "node scripts/pack-check.mjs"
12
12
  },
13
13
  "license": "MIT",
@@ -16,7 +16,12 @@
16
16
  },
17
17
  "description": "Session-scoped Psst messaging plugin for OpenCode",
18
18
  "main": "./plugin/index.mjs",
19
- "exports": "./plugin/index.mjs",
19
+ "exports": {
20
+ ".": "./plugin/index.mjs",
21
+ "./server": "./plugin/index.mjs",
22
+ "./tui": "./plugin/tui.mjs",
23
+ "./v2": "./plugin/v2.mjs"
24
+ },
20
25
  "files": [
21
26
  "plugin",
22
27
  "src",
@@ -27,7 +32,10 @@
27
32
  "docs",
28
33
  "LICENSE",
29
34
  "README.md",
30
- "vendor/broker/ORIGIN.md"
35
+ "vendor/broker/ORIGIN.md",
36
+ "vendor/enrollment",
37
+ "CHANGELOG.md",
38
+ "v2"
31
39
  ],
32
40
  "repository": {
33
41
  "type": "git",
@@ -35,5 +43,8 @@
35
43
  },
36
44
  "publishConfig": {
37
45
  "access": "public"
46
+ },
47
+ "bin": {
48
+ "psst-opencode": "bin/doctor.mjs"
38
49
  }
39
50
  }
package/plugin/index.mjs CHANGED
@@ -1,7 +1,19 @@
1
1
  import { tool } from '@opencode-ai/plugin';
2
- import { readConfig } from '../src/config.mjs';
3
2
  import { loadBrokerReference } from '../src/reference.mjs';
4
3
  import { createNativePlugin } from '../src/native-plugin.mjs';
4
+ import { createConnectionLoader, readExistingConnection } from '../src/enrollment.mjs';
5
+ import { readInstallation } from '../src/installation.mjs';
6
+ import { startPendingResume } from '../src/pending-resume.mjs';
7
+ import { createContextReceiver } from '../src/native-context.mjs';
5
8
  export default async function PsstPlugin(native) {
6
- return createNativePlugin({ native, tool, reference: await loadBrokerReference(), config: readConfig(process.env.PSST_CONFIG_FILE) });
9
+ const reference = await loadBrokerReference();
10
+ const config = process.env.PSST_CONFIG_FILE ? await readExistingConnection(process.env.PSST_CONFIG_FILE) : undefined;
11
+ const contextReceiver = createContextReceiver({ directory: native.directory });
12
+ const resume = startPendingResume();
13
+ try {
14
+ return await createNativePlugin({ native, tool, reference, config, contextReceiver,
15
+ connectFile: createConnectionLoader({ directory: native.directory }),
16
+ loadInstallation: readInstallation, onDispose: resume.stop,
17
+ experimentalNativeDelivery: process.env.PSST_EXPERIMENTAL_NATIVE_DELIVERY === 'fixed-session-snapshot' });
18
+ } catch (error) { resume.stop(); contextReceiver.close(); throw error; }
7
19
  }
package/plugin/tui.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import { deliveryCapability } from '../src/capabilities.mjs';
2
+ import { installSetupUI } from '../src/setup-ui.mjs';
3
+ import { readSelection, readPending } from '../src/installation.mjs';
4
+ import { startContextPublisher } from '../src/native-context.mjs';
5
+ export default { id: 'matuto.psst', tui: async api => {
6
+ startContextPublisher(api);
7
+ await installSetupUI(api);
8
+ const capability = deliveryCapability();
9
+ api.ui.toast({ title: 'Psst: automatic delivery blocked', variant: 'warning', duration: 10000, message: capability.next_action });
10
+ try {
11
+ if (!process.env.PSST_CONFIG_FILE && !readSelection()) {
12
+ const pending = readPending();
13
+ api.ui.toast({ title: 'Psst', variant: 'info', duration: 6000,
14
+ message: pending && !pending.paused ? 'Approval pending. Your saved request resumes without new enrollment.' : 'Setup needed: press Ctrl+Alt+P and enter the broker URL.' });
15
+ }
16
+ } catch { api.ui.toast({ title: 'Psst', variant: 'warning', message: 'Private configuration is invalid; no enrollment or credential reset was attempted.', duration: 6000 }); }
17
+ } };
package/plugin/v2.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { loadBrokerReference } from '../src/reference.mjs';
3
+ import { readExistingConnection } from '../src/enrollment.mjs';
4
+ import { readInstallation } from '../src/installation.mjs';
5
+ import { setupV2Plugin } from '../src/v2-plugin.mjs';
6
+
7
+ // Structurally the public @opencode/plugin Plugin.define({ id, setup }) contract.
8
+ // define is identity-only; no V2 host, runtime or CLI is installed by this package.
9
+ export default {
10
+ id: 'matuto.psst.v2',
11
+ async setup(ctx) {
12
+ const reference = await loadBrokerReference();
13
+ const connectionFile = ctx.options?.connectionFile ?? process.env.PSST_CONFIG_FILE;
14
+ const runtime = await setupV2Plugin({ ctx, reference, tool, setupEnabled: !connectionFile,
15
+ loadSettings: () => connectionFile ? readExistingConnection(connectionFile) : readInstallation() });
16
+ return runtime.dispose;
17
+ },
18
+ };
@@ -0,0 +1,11 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ export const APPROVAL_SHA256 = 'a60c3a0ccd7deb50556586cd67841bb89a300ea3c7f1af3a91d437f856b7a2d5';
5
+ export async function loadApprovalHelper() {
6
+ const path = fileURLToPath(new URL('../vendor/enrollment/approval.mjs', import.meta.url));
7
+ const stat = lstatSync(path);
8
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536
9
+ || createHash('sha256').update(readFileSync(path)).digest('hex') !== APPROVAL_SHA256) throw new Error('approval_snapshot_mismatch');
10
+ return import('../vendor/enrollment/approval.mjs');
11
+ }
@@ -0,0 +1,13 @@
1
+ /** Capability statements are not broker presence and never change host policy. */
2
+ export function deliveryCapability(env = process.env, { experimental = false } = {}) {
3
+ const pure = ['1', 'true'].includes(String(env.OPENCODE_PURE).toLowerCase());
4
+ return {
5
+ mode: 'automatic_required', ready: false,
6
+ delivery: experimental ? 'experimental_test_only' : 'automatic_blocked',
7
+ automatic_receive: false,
8
+ blocker: pure ? 'opencode_pure_mode' : 'classic_atomic_admission_unavailable',
9
+ next_action: pure
10
+ ? 'OPENCODE_PURE is enabled in this process. Ask the sandbox administrator to review the approved launcher source; the connector does not disable policy. No loaded plugin can diagnose itself when the host skips it entirely.'
11
+ : 'This classic OpenCode integration lacks safe automatic admission preserving the current agent/model and conversation. Transport may connect, but production messaging is not ready. Manual receive is diagnostic/test-only; do not enable a snapshot override as a fix.',
12
+ };
13
+ }
package/src/config.mjs CHANGED
@@ -4,19 +4,21 @@ const fail = () => { throw new Error('psst_configuration_invalid'); };
4
4
  const plain = value => value && typeof value === 'object' && !Array.isArray(value);
5
5
  export function readConfig(path) {
6
6
  if (typeof path !== 'string' || !isAbsolute(path)) fail();
7
- let fd;
7
+ let fd, marked = false;
8
8
  try {
9
9
  fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
10
10
  const stat = fstatSync(fd);
11
11
  if (!stat.isFile() || stat.mode & 0o077 || stat.size > 16384 || process.getuid && stat.uid !== process.getuid()) fail();
12
12
  let value = JSON.parse(readFileSync(fd, 'utf8'));
13
13
  if (value?.format === 'broker-client-bundle-v1') {
14
- const allowed = ['format', 'protocol_version', 'broker_url', 'token_file', 'file_paths_relative_to', 'authorization_header', 'authorization_scheme', 'insecure_development', 'allowed_client_types', 'scope', 'ca_file'];
14
+ const allowed = ['format', 'protocol_version', 'broker_url', 'token_file', 'file_paths_relative_to', 'authorization_header', 'authorization_scheme', 'insecure_development', 'allowed_client_types', 'scope', 'ca_file', 'enrollment_receipt_file'];
15
15
  if (Object.keys(value).some(key => !allowed.includes(key)) || value.protocol_version !== '1'
16
16
  || value.file_paths_relative_to !== 'connection_file_directory' || value.authorization_header !== 'Authorization'
17
17
  || value.authorization_scheme !== 'Bearer' || value.insecure_development !== false
18
18
  || !Array.isArray(value.allowed_client_types) || value.allowed_client_types.length !== 1 || value.allowed_client_types[0] !== 'opencode'
19
+ || value.enrollment_receipt_file !== undefined && value.enrollment_receipt_file !== 'enrollment.json'
19
20
  || value.token_file !== 'token' || typeof value.scope !== 'string') fail();
21
+ marked = value.enrollment_receipt_file === 'enrollment.json';
20
22
  const base = dirname(realpathSync(path));
21
23
  value = { broker: { url: value.broker_url, tokenFile: resolve(base, value.token_file),
22
24
  ...(value.ca_file ? { caFile: resolve(base, value.ca_file) } : {}) }, stateDirectory: resolve(base, 'state') };
@@ -29,6 +31,7 @@ export function readConfig(path) {
29
31
  || value.name !== undefined && (typeof value.name !== 'string' || !value.name.trim() || value.name.length > 100 || /[\x00-\x1f\x7f]/.test(value.name))) fail();
30
32
  const url = new URL(value.broker.url);
31
33
  if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') fail();
34
+ if (marked) Object.defineProperty(value, 'enrollmentReceiptRequired', { value: true });
32
35
  return value;
33
36
  } catch { fail(); }
34
37
  finally { if (fd !== undefined) closeSync(fd); }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { LocalError } from '../vendor/broker/reference/errors.mjs';
3
- import { provenance } from '../vendor/broker/reference/runtime.mjs';
3
+ import { nativeMessage } from './message-format.mjs';
4
4
  import { validateUuid } from '../vendor/broker/src/protocol.mjs';
5
5
 
6
6
  /** Application-level explicit read ACK. Never a native injection receipt or action success. */
@@ -12,29 +12,34 @@ export function createCooperativeInbox({ inbox, isActive = () => true }) {
12
12
  const active = () => { if (isActive() !== true) throw new LocalError('session_mismatch'); inbox.assertUsable(); };
13
13
  return {
14
14
  async broker_receive(args = {}) {
15
- if (!args || Array.isArray(args) || typeof args !== 'object' || Object.keys(args).length) throw new LocalError('invalid_configuration');
15
+ if (!args || Array.isArray(args) || typeof args !== 'object' || Object.keys(args).some(key => key !== 'diagnostic')) throw new LocalError('invalid_configuration');
16
+ if (args.diagnostic !== true) { const error = new LocalError('unsafe_boundary', {field:'automatic_delivery_required'}); error.message = 'Automatic delivery is required. Manual receive is available only for an explicitly requested diagnostic test with diagnostic:true; nothing was consumed.'; throw error; }
16
17
  active();
17
18
  return inbox.transaction(() => {
18
19
  const head = inbox.head();
19
- if (!head) return { message: null, local_mode: 'cooperative_pull' };
20
+ if (!head) return { message: null, local_mode: 'diagnostic_pull' };
20
21
  if (head.state !== 'pending') throw new LocalError('injection_uncertain', { effect: 'unknown' });
21
22
  let receipt = inbox.db.prepare('SELECT receipt_token FROM cooperative_receipts WHERE message_id=?').get(head.message_id);
22
23
  if (!receipt) {
23
24
  receipt = { receipt_token: randomUUID() };
24
25
  inbox.db.prepare('INSERT INTO cooperative_receipts(message_id,receipt_token,exposed_at) VALUES(?,?,?)').run(head.message_id, receipt.receipt_token, inbox.now());
25
26
  }
26
- return { message_id: head.message_id, message: provenance(head.envelope), receipt_token: receipt.receipt_token,
27
- local_state: 'pending', local_mode: 'cooperative_pull', native_acceptance_recorded: false,
27
+ return { message_id: head.message_id, message: nativeMessage(head.envelope),
28
+ local_state: 'pending', local_mode: 'diagnostic_pull', native_acceptance_recorded: false,
28
29
  acknowledgment_required: true };
29
30
  }, { growth: 4096 });
30
31
  },
31
32
  async broker_acknowledge(args) {
32
- if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).sort().join(',') !== 'message_id,receipt_token') throw new LocalError('invalid_configuration');
33
- try { validateUuid(args.message_id); validateUuid(args.receipt_token); } catch { throw new LocalError('invalid_configuration'); }
33
+ if (!args || typeof args !== 'object' || Array.isArray(args) || !['message_id','message_id,receipt_token'].includes(Object.keys(args).sort().join(','))) throw new LocalError('invalid_configuration');
34
+ try { validateUuid(args.message_id); if (args.receipt_token !== undefined) validateUuid(args.receipt_token); } catch { throw new LocalError('invalid_configuration'); }
34
35
  active();
35
36
  return inbox.transaction(() => {
36
37
  const receipt = inbox.db.prepare('SELECT receipt_token,acknowledged_at FROM cooperative_receipts WHERE message_id=?').get(args.message_id);
37
- if (!receipt || receipt.receipt_token !== args.receipt_token) throw new LocalError('receipt_required');
38
+ if (!receipt || args.receipt_token !== undefined && receipt.receipt_token !== args.receipt_token) {
39
+ const error = new LocalError('receipt_required', {field: receipt ? 'receipt_token' : 'message_id'});
40
+ error.message = receipt ? 'The supplied legacy read receipt does not match. Nothing was acknowledged; this is not a transient network error. Omit the token and use the exact previously read message_id.' : 'Read this exact message in this inbox before acknowledging it. Nothing was acknowledged; this is not a transient network error.';
41
+ throw error;
42
+ }
38
43
  if (receipt.acknowledged_at == null) {
39
44
  const head = inbox.head();
40
45
  if (!head || head.message_id !== args.message_id || head.state !== 'pending') throw new LocalError('unsafe_boundary');
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, lstatSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve, isAbsolute } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { readConfig } from './config.mjs';
7
+
8
+ export const ENROLLMENT_SHA256 = 'afeab00d7c2d6768d3537bd7c350e249768897bf81eff9b4ae9796f7c3f9dbe9';
9
+ async function helper() {
10
+ const path = fileURLToPath(new URL('../vendor/enrollment/enrollment.mjs', import.meta.url));
11
+ const stat = lstatSync(path);
12
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536
13
+ || createHash('sha256').update(readFileSync(path)).digest('hex') !== ENROLLMENT_SHA256) throw new Error('enrollment_snapshot_mismatch');
14
+ return import('../vendor/enrollment/enrollment.mjs');
15
+ }
16
+
17
+ /** Legacy environment mode may load an existing bundle, never issue credentials. */
18
+ export async function readExistingConnection(path) {
19
+ const settings = readConfig(path);
20
+ if (settings.enrollmentReceiptRequired) {
21
+ settings.enrollment = (await helper()).readEnrollmentReceipt({ connectionFile: path, clientType: 'opencode' });
22
+ }
23
+ return settings;
24
+ }
25
+
26
+ /** Only the native explicit-connect command calls this. No model tool accepts a secret. */
27
+ export function createConnectionLoader({ directory, stateHome = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'),
28
+ loadHelper = helper } = {}) {
29
+ if (!isAbsolute(directory) || !isAbsolute(stateHome)) throw new Error('private_directory_required');
30
+ return async function connectFile(path, { clientType, sessionID }) {
31
+ if (clientType !== 'opencode' || typeof path !== 'string' || !isAbsolute(path)
32
+ || /[\x00-\x1f\x7f]/.test(path) || typeof sessionID !== 'string' || !sessionID) throw new Error('private_file_required');
33
+ let settings;
34
+ try { settings = readConfig(path); } catch { /* The shared helper independently validates pairing files. */ }
35
+ if (settings) {
36
+ // The explicit bundle marker makes missing/corrupt receipts fatal.
37
+ if (settings.enrollmentReceiptRequired) {
38
+ const api = await loadHelper();
39
+ settings.enrollment = api.readEnrollmentReceipt({ connectionFile: path, clientType });
40
+ }
41
+ } else {
42
+ const api = await loadHelper();
43
+ const key = createHash('sha256').update(JSON.stringify([resolve(directory), sessionID])).digest('hex');
44
+ const result = await api.connectPairingFile({ pairingFile: path,
45
+ directory: join(stateHome, 'psst', 'opencode', key), clientType });
46
+ settings = readConfig(result.connectionFile);
47
+ settings.enrollment = api.readEnrollmentReceipt({ connectionFile: result.connectionFile, clientType });
48
+ }
49
+ if (settings.enrollment && Date.parse(settings.enrollment.expiresAt) <= Date.now()) throw new Error('credential_expired');
50
+ return settings;
51
+ };
52
+ }