opencode-ext-connector 0.5.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-09-23
4
+
5
+ - Add the opt-in `xaiOAuth.mode: "authority" | "consumer"` package subpath
6
+ pairing a host-side authority with a guest-side consumer; omitting
7
+ `xaiOAuth` disables the integration silently, while a present `xaiOAuth`
8
+ whose `mode` is missing or unknown is rejected at parse time
9
+ - Ship the dedicated `./xai` consumer as a separate OpenCode plugin entry
10
+ that returns the xAI auth hook only with `methods: []`
11
+ - Invoke the fixed helper at `${HOME}/.local/bin/opensandbox-xai-auth-sync`
12
+ with no arguments, `PATH=/usr/local/bin:/usr/bin:/bin`, `HOME` always
13
+ set, and `XDG_DATA_HOME` forwarded only when absolute; the helper
14
+ delegates projection to the OpenSandbox manager, and a non-zero exit
15
+ retries after `5000` ms without removing the xAI provider
16
+ - Re-read the access file and `expires` on every outbound request and raise
17
+ `XaiAccessUnavailableError` before any network call on a missing file,
18
+ a stat that fails the closed-v1 `0600`/single-link/current-user gates,
19
+ a schema mismatch, a `"state": "unavailable"` record, malformed JSON,
20
+ or a non-future `expires`
21
+ - Never place a refresh token inside the guest; refresh stays on the host
22
+ authority only
23
+ - Keep existing Claude, Cursor, Command Code, and Ollama providers and
24
+ omitted-mode behavior unchanged
25
+
26
+ ## 0.6.0 - 2026-09-16
27
+
28
+ - Add the recommended `credentialRole: "owner" | "reader"` option for shared
29
+ Claude logins: one Linux owner runs the existing Claude CLI authority while
30
+ readers consume externally managed credentials without refreshing them
31
+ - Keep the existing low-level credential policy options available for advanced
32
+ control, but reject combining any of them with `credentialRole`
33
+ - Keep omitted credential policy behavior and non-Claude providers unchanged
34
+
3
35
  ## 0.5.0 - 2026-09-15
4
36
 
5
37
  - Add the disabled-by-default Claude CLI credential authority for Linux
package/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  ## Status
21
21
 
22
- > Independent unofficial community plugin, version **0.5.0**. Package E2E tests exercise the legacy multi-function loader with the OpenCode CLI installed in CI. `@opencode-ai/plugin@1.18.18` is the compile-time plugin API target, not a runtime pin. Source is BSD-3-Clause. This project is not affiliated with, endorsed by, sponsored by, or authorized by OpenCode or any provider. Full terms are in [License and Disclaimer](#license-and-disclaimer).
22
+ > Independent unofficial community plugin, version **0.7.0**. Package E2E tests exercise the legacy multi-function loader with the OpenCode CLI installed in CI. `@opencode-ai/plugin@1.18.18` is the compile-time plugin API target, not a runtime pin. Source is BSD-3-Clause. This project is not affiliated with, endorsed by, sponsored by, or authorized by OpenCode or any provider. Full terms are in [License and Disclaimer](#license-and-disclaimer).
23
23
 
24
24
  Reuse the Claude, Cursor, Command Code, and Ollama sessions you already have. One `opencode.json` plugin entry publishes live catalogs into OpenCode. Claude and Cursor stay disconnected until OpenCode has a marker or OAuth record and the vendor session is present. Command Code may use an OpenCode-stored direct API key or an existing CLI session/key. Ollama requires the exact session marker plus a responsive trusted daemon.
25
25
 
@@ -55,13 +55,13 @@ OpenCode installs configured npm plugins with Bun at startup and caches them. Fo
55
55
  ```jsonc
56
56
  {
57
57
  "$schema": "https://opencode.ai/config.json",
58
- "plugin": ["opencode-ext-connector@0.5.0"]
58
+ "plugin": ["opencode-ext-connector@0.7.0"]
59
59
  }
60
60
  ```
61
61
 
62
62
  Fully quit and restart OpenCode after adding or changing the entry; a reload is not enough.
63
63
 
64
- The one package entry exposes the catalog plugin plus the Claude, Cursor, Command Code, and Ollama auth hooks. Provider ids: `claude`, `cursor`, `command-code`, `ollama`. Model ids come from each provider's live catalog, with documented fallbacks `default` (Cursor) and `Qwen/Qwen3.8-Max` (Command Code) when a live list is empty.
64
+ The one package entry exposes the catalog plugin plus the Claude, Cursor, Command Code, and Ollama auth hooks. Provider ids: `claude`, `cursor`, `command-code`, `ollama`. Model ids come from each provider's live catalog, with documented fallbacks `default` (Cursor) and `Qwen/Qwen3.8-Max` (Command Code) when a live list is empty. The opt-in xAI consumer ships as the package subpath `opencode-ext-connector/xai`; see [Opt-in xAI OAuth authority and consumer](#opt-in-xai-oauth-authority-and-consumer-host-and-guest).
65
65
 
66
66
  ## Configuration
67
67
 
@@ -73,10 +73,12 @@ Omitted `providers` enables all four. An explicit list is a strict allow-list. E
73
73
  | --- | --- | --- |
74
74
  | `providers` | all four | Provider ids to register: `claude`, `cursor`, `command-code`, `ollama`; explicit `[]` disables all |
75
75
  | `ollamaBaseURL` | `"http://localhost:11434"` | Absolute `http` or `https` base for the trusted Ollama daemon; path prefixes are preserved |
76
- | `credentialManagement` | omitted | Preferred authority policy: `"connector"` authorizes refresh and writeback where an adapter supports both; `"external"` prohibits connector refresh and writeback |
76
+ | `credentialRole` | omitted | Recommended shared-login setup: `"owner"` runs the Claude CLI authority on the one credential-owning instance; `"reader"` only reads externally managed credentials |
77
+ | `credentialManagement` | omitted | Advanced policy: `"connector"` authorizes refresh and writeback where an adapter supports both; `"external"` prohibits connector refresh and writeback |
77
78
  | `writeBackCredentials` | `false` | **Deprecated:** accepted alone for one migration cycle; controls Claude writeback after refresh |
78
79
  | `credentialRefresh.mode` | `"auto"` | **Deprecated:** accepted alone for one migration cycle; controls Claude `"auto"` or `"never"` refresh behavior |
79
80
  | `credentialRefresh.leadMs` | `60000` | **Deprecated:** accepted alone for one migration cycle; custom lead times still require this legacy configuration |
81
+ | `xaiOAuth.mode` | omitted | Opt-in xAI role; `"authority"` enables the host observer on the `connectorServer` entry and `"consumer"` activates the dedicated `opencode-ext-connector/xai` consumer. Omitting `xaiOAuth` disables the integration silently. A present `xaiOAuth` whose `mode` is missing or not one of `"authority" | "consumer"` is rejected at parse time. Independent of every Claude credential option. |
80
82
  | `credentialAuthority.claudeCli.enabled` | `false` | Claude-only opt-in: see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only) |
81
83
  | `credentialAuthority.claudeCli.leadMs` | `300000` | Claude-only opt-in: milliseconds before credential expiry at which the timer invokes `claude`; non-negative integer |
82
84
  | `credentialAuthority.claudeCli.retryMs` | `300000` | Claude-only opt-in: milliseconds to wait before retrying after a non-zero exit, lock conflict, signal, or supervisor failure; positive integer |
@@ -85,6 +87,34 @@ Omitted `providers` enables all four. An explicit list is a strict allow-list. E
85
87
  | `health.initialBackoffMs` | `1000` | Health backoff after a failed snapshot |
86
88
  | `health.maximumBackoffMs` | `60000` | Health backoff cap |
87
89
 
90
+ ### Credential ownership (recommended)
91
+
92
+ Choose `"owner"` on the one Linux instance that owns and refreshes the shared Claude Code login:
93
+
94
+ ```jsonc
95
+ {
96
+ "$schema": "https://opencode.ai/config.json",
97
+ "plugin": [["opencode-ext-connector", { "credentialRole": "owner" }]]
98
+ }
99
+ ```
100
+
101
+ Choose `"reader"` on every instance that only consumes synchronized credentials:
102
+
103
+ ```jsonc
104
+ {
105
+ "$schema": "https://opencode.ai/config.json",
106
+ "plugin": [["opencode-ext-connector", { "credentialRole": "reader" }]]
107
+ }
108
+ ```
109
+
110
+ `"owner"` internally selects external credential management and enables the Claude CLI authority with its default timing. `"reader"` selects external credential management without starting the CLI authority. The role describes credential ownership, not whether OpenCode runs on a physical host or in a sandbox guest.
111
+
112
+ Set exactly one owner per shared Claude login. Owner mode requires Linux, util-linux `flock`, Claude Code `2.1.259` or later on `PATH`, and an authenticated Claude Code session. Each authority invocation is a real model request and may consume account usage. A revoked login still requires interactive `/login`.
113
+
114
+ Do not combine `credentialRole` with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials`. Existing low-level configurations remain supported for advanced control.
115
+
116
+ ### Advanced credential policy
117
+
88
118
  `credentialManagement: "connector"` authorizes the connector to refresh and write back credentials where a provider adapter supports both. Today only Claude has that capability, mapping to automatic refresh with a `60_000` ms lead and writeback enabled:
89
119
 
90
120
  ```jsonc
@@ -143,17 +173,17 @@ With all credential-policy options omitted, refreshed Claude tokens stay in memo
143
173
 
144
174
  Anthropic rotates the refresh token on every refresh and invalidates the previous one. Two copies of `~/.claude/.credentials.json` that both refresh will therefore break each other. Copying the file works only if exactly one machine refreshes and every other machine receives the result before its own copy expires:
145
175
 
146
- - **Refresh authority** (where you log in): choose exactly one refresh path. Use `credentialManagement: "connector"`, or on Linux use `credentialManagement: "external"` with the opt-in [Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only). The parser rejects enabling the CLI timer in connector mode. If you need a custom publication window for connector mode, use the deprecated legacy options alone for this migration cycle, for example `writeBackCredentials: true` with `credentialRefresh: { mode: "auto", leadMs: 1800000 }`.
147
- - **External-authority machines**: `credentialManagement: "external"`. They never contact the OAuth endpoint; when a request returns 401 they re-read externally managed credentials and retry once.
176
+ - **Credential owner** (where you log in): use `credentialRole: "owner"` on one Linux instance. For advanced control, use `credentialManagement: "connector"`, or combine `credentialManagement: "external"` with the opt-in [Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only).
177
+ - **Credential readers**: use `credentialRole: "reader"`. They never contact the OAuth endpoint; when a request returns 401 they re-read externally managed credentials and retry once.
148
178
  - Synchronize the externally managed credential material from the refresh-authority machine whenever it changes. The option itself does not synchronize machines or require file storage; if you copy `~/.claude/.credentials.json`, OpenCode's own `auth.json` only needs the `anthropic` record once and its other providers should remain untouched.
149
179
 
150
180
  Machines that refresh on their own — including a Claude Code install that is used interactively — must not share the file. Log in separately there.
151
181
 
152
182
  ### Opt-in Claude CLI authority timer (Claude-only)
153
183
 
154
- This Linux-only option lets one host use an existing Claude Code login as the refresh authority for an externally managed credential file. It is disabled by default, does not log in or mint tokens, and requires `credentialManagement: "external"`.
184
+ This is the advanced form of `credentialRole: "owner"`, useful when custom `leadMs` or `retryMs` values are required. It is disabled by default, does not log in or mint tokens, and requires `credentialManagement: "external"`.
155
185
 
156
- Minimal configuration:
186
+ Low-level equivalent:
157
187
 
158
188
  ```jsonc
159
189
  {
@@ -182,6 +212,58 @@ Each invocation is a real model request and can count against the account's subs
182
212
 
183
213
  If the timer does not run, check `flock --version`, `claude --version`, the state directory permissions, and both required configuration values. Repeated warnings indicate that the CLI failed, was signalled, or could not be started. To disable the timer, remove `credentialAuthority` or set `enabled` to `false`, then fully restart OpenCode.
184
214
 
215
+ ### Opt-in xAI OAuth authority and consumer (host and guest)
216
+
217
+ This connector ships an opt-in xAI path that pairs a host-side **authority** with a guest-side **consumer**. The roles are independent of `credentialRole`, `credentialManagement`, `credentialAuthority`, `credentialRefresh`, and `writeBackCredentials`, and never modify or replace any Claude policy. Omitting `xaiOAuth` disables the integration silently. A present `xaiOAuth` whose `mode` is missing or not one of `"authority" | "consumer"` is rejected at parse time.
218
+
219
+ Configure the two roles in separate OpenCode environments. On the host:
220
+
221
+ ```jsonc
222
+ {
223
+ "$schema": "https://opencode.ai/config.json",
224
+ "plugin": [["opencode-ext-connector", { "xaiOAuth": { "mode": "authority" } }]]
225
+ }
226
+ ```
227
+
228
+ In the guest:
229
+
230
+ ```jsonc
231
+ {
232
+ "$schema": "https://opencode.ai/config.json",
233
+ "plugin": [["opencode-ext-connector/xai", { "xaiOAuth": { "mode": "consumer" } }]]
234
+ }
235
+ ```
236
+
237
+ **Authority (host).** Set `xaiOAuth.mode: "authority"` on the host `connectorServer` entry. The observer reads `${XDG_DATA_HOME}/opencode/auth.json` when `XDG_DATA_HOME` is set and absolute, or `${HOME}/.local/share/opencode/auth.json` on Linux only when `XDG_DATA_HOME` is unset or empty. A relative `XDG_DATA_HOME` (set but not absolute) disables the authority: it neither reads `auth.json` nor invokes the helper. When enabled, the observer inspects **only** the `xai` record. When that record's serialized fingerprint changes, the observer invokes the fixed, no-argument helper at `${HOME}/.local/bin/opensandbox-xai-auth-sync` with `PATH` set to `/usr/local/bin:/usr/bin:/bin`, `HOME` always set, and `XDG_DATA_HOME` forwarded when absolute. The helper delegates projection to the OpenSandbox manager; the connector never writes the guest access file, mints or refreshes xAI OAuth, ships tokens across machines, or places a refresh token inside the guest. A non-zero helper exit is treated as transient and retried after `5000` ms without removing the xAI provider.
238
+
239
+ **Consumer (guest).** Load the consumer as a separate OpenCode plugin entry as the tuple `["opencode-ext-connector/xai", { "xaiOAuth": { "mode": "consumer" } }]`. The mode is required: a subpath entry without it returns an empty hook and the xAI provider stays disconnected. With mode `"consumer"` the connector returns the xAI auth hook and exposes no `/connect` methods — `methods: []` is part of the public contract. The consumer requires the exact marker
240
+
241
+ ```jsonc
242
+ { "type": "api", "key": "cli-session:xai" }
243
+ ```
244
+
245
+ in the OpenCode auth record for the `xai` provider. The connector does not place this marker; provisioning it is the operator's responsibility. Any other shape means the loader returns no auth. As a sentinel, the loader returns a placeholder `apiKey` plus a wrapped `fetch`; the real bearer is sourced from the access file on every outbound request.
246
+
247
+ **Access file.** The consumer re-reads the access file on **every** request and never trusts an in-memory token:
248
+
249
+ - Path: `${XDG_DATA_HOME}/opencode/xai-access.json` when `XDG_DATA_HOME` is set and absolute, or `${HOME}/.local/share/opencode/xai-access.json` on Linux when `XDG_DATA_HOME` is unset or empty. A relative `XDG_DATA_HOME` fails closed without inspecting the HOME fallback.
250
+ - File gates: regular file, mode `0600`, link count `1`, owned by the current user. A missing file, a non-conforming stat, a symlink, a multi-link file, the wrong owner, or the wrong mode all return the unavailable state.
251
+ - Schema (closed v1):
252
+
253
+ ```jsonc
254
+ {
255
+ "schema_version": 1,
256
+ "provider": "xai",
257
+ "state": "ready",
258
+ "access": "<bearer-token>",
259
+ "expires": 1735689600000
260
+ }
261
+ ```
262
+
263
+ A `"state": "unavailable"` record, malformed JSON, a schema mismatch, an absent file, a failed stat, or an `expires` not strictly greater than the current time all fail **before** any network call, raising `XaiAccessUnavailableError` for the request. The bearer token never enters plugin arguments, connector stdout/stderr diagnostics, or request URLs.
264
+
265
+ **Out of scope.** Ordinary xAI API keys stored directly in OpenCode auth (the built-in `XAI_API_KEY`-style path) follow OpenCode's built-in `xai` provider, not this authority/consumer pair. The connector never imports, mirrors, or proxies such keys. The helper must be idempotent; running a developer Claude Code session in the same guest does not give it authority over the projected access file.
266
+
185
267
  ## Host/Guest Sandbox Setup
186
268
 
187
269
  When OpenCode runs in a container, VM, or another sandbox, treat that runtime as the **guest** and the machine that owns the vendor logins and Ollama daemon as the **host**. The guest has its own `localhost`, home directory, environment, keychains, filesystem permissions, and network namespace. Host sessions are not visible unless you mount their files or inject their environment values explicitly.
@@ -190,10 +272,11 @@ The safest shared-session layout keeps each vendor login owned and refreshed by
190
272
 
191
273
  | Provider | Host | Guest |
192
274
  | --- | --- | --- |
193
- | Claude | Own and refresh the Claude Code login | Mount the Claude credential directory read-only, set `CLAUDE_CONFIG_DIR` to that guest path, and use `credentialManagement: "external"`; a host macOS Keychain is not available inside a Linux guest; resolve the client version with `ANTHROPIC_CLI_VERSION`, an installed `claude` binary, or npm registry access |
275
+ | Claude | Own and refresh the Claude Code login | Mount the Claude credential directory read-only, set `CLAUDE_CONFIG_DIR` to that guest path, and use `credentialRole: "reader"`; a host macOS Keychain is not available inside a Linux guest; resolve the client version with `ANTHROPIC_CLI_VERSION`, an installed `claude` binary, or npm registry access |
194
276
  | Cursor | Own the Cursor CLI login | Mount the credential file at the guest's `${HOME}/.config/cursor/auth.json` read-only, or inject `CURSOR_ACCESS_TOKEN` through the sandbox's secret mechanism; install Node.js 22 or later in the guest |
195
277
  | Command Code | Own the CLI login or API key | Mount `${HOME}/.commandcode/auth.json` read-only, or inject `COMMAND_CODE_API_KEY`; resolve the client version with `COMMAND_CODE_CLI_VERSION`, an installed `command-code` binary, or npm registry access |
196
278
  | Ollama | Run the trusted daemon and run `ollama signin` there when Cloud access is needed | Copy no Ollama credential; connect only to the daemon selected by `ollamaBaseURL` |
279
+ | xAI | Host authority invokes `~/.local/bin/opensandbox-xai-auth-sync`; the OpenSandbox manager projects access-only guest state | Use the manager-projected `{"type":"api","key":"cli-session:xai"}` marker and `${XDG_DATA_HOME}/opencode/xai-access.json` (or the HOME fallback when XDG is unset or empty), then load `opencode-ext-connector/xai` with `xaiOAuth.mode: "consumer"`; never copy or mount a refresh token |
197
280
 
198
281
  For example, a Linux guest can use these paths and optional secret/version overrides; adapt the mount source and destination to the sandbox runtime:
199
282
 
@@ -220,7 +303,7 @@ Use this complete guest `opencode.json` when all four providers are enabled and
220
303
  {
221
304
  "providers": ["claude", "cursor", "command-code", "ollama"],
222
305
  "ollamaBaseURL": "http://host.docker.internal:11434",
223
- "credentialManagement": "external",
306
+ "credentialRole": "reader",
224
307
  "catalogReloadMs": 300000,
225
308
  "snapshotTimeoutMs": 30000,
226
309
  "health": {
@@ -233,7 +316,7 @@ Use this complete guest `opencode.json` when all four providers are enabled and
233
316
  }
234
317
  ```
235
318
 
236
- `ollamaBaseURL` is a flat connector option in the package tuple, not an OpenCode provider option. The numeric values above are the connector defaults; `credentialManagement: "external"` and the host daemon URL are deliberate overrides for read-only host-owned credentials. Do not put vendor tokens in `opencode.json`; pass them through read-only mounts or the sandbox's secret injection mechanism.
319
+ `ollamaBaseURL` is a flat connector option in the package tuple, not an OpenCode provider option. The numeric values above are the connector defaults; `credentialRole: "reader"` and the host daemon URL are deliberate overrides for read-only host-owned credentials. Do not put vendor tokens in `opencode.json`; pass them through read-only mounts or the sandbox's secret injection mechanism.
237
320
 
238
321
  For Docker Desktop, `host.docker.internal` normally resolves to the host. A Linux Docker bridge may also need `--add-host=host.docker.internal:host-gateway` or the Compose equivalent:
239
322
 
@@ -244,7 +327,7 @@ extra_hosts:
244
327
 
245
328
  Ollama normally listens on host loopback. For bridge networking, the host may need to start it with `OLLAMA_HOST=0.0.0.0:11434`; restrict the exposed port with host firewall and sandbox network policy. `OLLAMA_HOST` configures the host daemon, while `ollamaBaseURL` configures this connector in the guest. Host networking makes guest `localhost` reach the host but reduces isolation and should be an explicit choice. Other sandbox runtimes need an equivalent host route and must allow outbound access to each enabled provider; allow `registry.npmjs.org` only when Claude or Command Code cannot resolve its client version from an environment value or installed binary.
246
329
 
247
- Alternatively, the guest can own its vendor logins in persistent guest storage. In that mode, run vendor login flows in the guest instead of mounting host credentials. A guest that is the sole Claude refresh owner may use `credentialManagement: "connector"`. Never let the host and guest independently refresh credentials descended from the same Claude refresh token.
330
+ Alternatively, the guest can own its vendor logins in persistent guest storage. In that mode, run vendor login flows in the guest instead of mounting host credentials. A Linux guest that is the sole Claude refresh owner may use `credentialRole: "owner"`; low-level `credentialManagement: "connector"` remains available for connector-managed OAuth refresh and writeback. Never let the host and guest independently refresh credentials descended from the same Claude refresh token.
248
331
 
249
332
  ## Update and Remove
250
333
 
@@ -278,15 +361,18 @@ Ollama `/connect` probes the configured daemon and stores the exact session mark
278
361
 
279
362
  | Provider | What it does |
280
363
  | --- | --- |
281
- | **Claude** | Reuses existing Claude Code credentials. Does not mint OAuth. Compatibility fetch sends CLI-compatible request metadata and streams Anthropic SSE on the built-in `anthropic` path. `credentialManagement: "connector"` maps to auto-refresh with a `60_000` ms lead and writeback; `"external"` maps to never-refresh/no-write with a credential re-read after 401. On Linux only, `credentialManagement: "external"` plus the opt-in `credentialAuthority.claudeCli.enabled: true` timer (see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only)) can keep an externally managed credential file current by invoking the local `claude` CLI; the timer is off by default and does not log in or extend the underlying session lifetime. Omitting all credential-policy options preserves legacy auto/`60_000` behavior without writeback; if only `credentialManagement` is omitted, supplied deprecated options still control behavior. |
364
+ | **Claude** | Reuses existing Claude Code credentials. Does not mint OAuth. Compatibility fetch sends CLI-compatible request metadata and streams Anthropic SSE on the built-in `anthropic` path. For shared logins, `credentialRole: "owner"` enables the Linux-only Claude CLI authority on one instance, while `"reader"` keeps every other instance read-only with a credential re-read after 401. Low-level `credentialManagement: "connector"` maps to auto-refresh with a `60_000` ms lead and writeback; `"external"` maps to never-refresh/no-write. Omitting all credential-policy options preserves legacy auto/`60_000` behavior without writeback; if only `credentialManagement` is omitted, supplied deprecated options still control behavior. |
282
365
  | **Cursor** | Calls Cursor's unpublished client protocol (`api2.cursor.sh` `AgentService`, Connect+protobuf over HTTP/2) with the CLI access token. Credentials remain read-only under both credential-management modes. Direct generation may re-read a changed, non-null credential and retry once only on an exact HTTP 401 before output or effects; this is not refresh or writeback. Legacy/compatibility generation remains one-shot. A plugin-owned Node child communicates over private stdio, keeps tool results on the same bidi Run, never replays parked calls, opens no user-facing daemon, and never spawns `cursor-agent` for generation. Unofficial; not a public Cursor API. After protocol drift there is no implicit fallback — that provider fails. Requires Node.js 22 or later. Live catalog ids are used when present; otherwise the documented fallback is `default`. |
283
366
  | **Command Code** | Calls `/alpha/generate` with CLI-compatible request metadata and streams provider-local NDJSON text and tool events. Credentials remain read-only under both credential-management modes. On an exact HTTP 401 before output or effects, it may re-read a changed, non-null credential and retry once only; this is not refresh or writeback. The client version comes from `COMMAND_CODE_CLI_VERSION`, an installed `command-code` binary, or the npm registry. Request metadata includes Node.js version, platform, architecture, and the absolute working directory. Live catalog ids are used when present; otherwise the documented fallback is `Qwen/Qwen3.8-Max`. |
284
367
  | **Ollama** | Unaffected by `credentialManagement`. Uses the trusted daemon selected by `ollamaBaseURL` (default `http://localhost:11434`) with `/api/tags`, `/api/pull`, and `/api/chat`; path prefixes are preserved. Publishes models already pulled there, plus exact Cloud tags discovered anonymously from Ollama's official Cloud search and library pages, without connector-supplied credentials. Local entries win exact duplicates. Incomplete Cloud refreshes retain the last complete list. Selecting an absent authorized Cloud tag pulls its lightweight remote reference on first use; concurrent pulls of the same tag and normalized base share one in-flight request, and a failed pull can be retried later. The daemon may then proxy Cloud-tag prompts under the user's Ollama Cloud subscription. The connector never uses an Ollama API key, the usage-billed direct Cloud API, `OLLAMA_HOST`, credentials, custom headers, cookies, or a direct Cloud generation endpoint. |
368
+ | **xAI** | Opt-in OAuth consumer loaded only through the package subpath `opencode-ext-connector/xai`. The access file is re-read on every request, exposes no `/connect` methods, and never mints or refreshes xAI OAuth. Independent of Claude `credentialRole` and credential policy. See [Opt-in xAI OAuth authority and consumer](#opt-in-xai-oauth-authority-and-consumer-host-and-guest). |
285
369
 
286
370
  Provider health is isolated: one provider failing does not remove the others.
287
371
 
288
372
  The standalone SDK entry is `opencode-ext-connector/ollama`; pass `{ ollamaBaseURL }` to select the same trusted daemon. It can generate with models already present there; connector-managed Cloud auto-pull requires an active Ollama catalog lease for that normalized base.
289
373
 
374
+ The dedicated consumer entry is `opencode-ext-connector/xai`; pass `{ xaiOAuth: { mode: "consumer" } }` to register the xAI auth hook without registering any model providers, models, or `/connect` methods.
375
+
290
376
  ## Troubleshooting
291
377
 
292
378
  | Symptom | What to check |
@@ -294,12 +380,14 @@ The standalone SDK entry is `opencode-ext-connector/ollama`; pass `{ ollamaBaseU
294
380
  | `/connect` methods missing | Confirm `plugin` contains `"opencode-ext-connector"` or an exact published `"opencode-ext-connector@<version>"` spec, then fully restart OpenCode. |
295
381
  | Provider enabled but no models | Omitted `providers` enables all four; an explicit list is a strict allow-list. Claude and Cursor need a marker or OAuth record plus the vendor session; Command Code may use an OpenCode-stored API key or a CLI session/key; Ollama needs the exact marker plus a responsive configured daemon. Fully restart after `/connect` so instance reconstruction picks up new membership. |
296
382
  | Claude works until the next start | Omitting all credential-policy options preserves legacy in-memory refresh without writeback. A rotated refresh token can then fail on the next process start; use `credentialManagement: "connector"` when the connector should refresh and write back. If only `credentialManagement` is omitted, check supplied deprecated refresh/writeback options instead. |
297
- | Claude reports `invalid_grant` on shared credentials | Another machine with the same login already refreshed and rotated the refresh token. Every refresher counts: `credentialManagement: "connector"` refreshes through the OAuth endpoint, and the opt-in `credentialAuthority.claudeCli.enabled` timer refreshes through the local `claude` CLI. Pick exactly one authority: either connector mode, or external mode with the CLI timer on one Linux machine. Keep every other machine on `"external"` with the timer disabled, or log in separately. |
298
- | Configuration rejects credential options | Do not combine the new and legacy options; the exact error is: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``. Legacy options remain accepted alone for one migration cycle. |
383
+ | Claude reports `invalid_grant` on shared credentials | Another machine with the same login already refreshed and rotated the refresh token. Set `credentialRole: "owner"` on exactly one Linux instance and `"reader"` everywhere else, or use exactly one documented low-level authority path. Every refresher counts, including connector OAuth refresh and the Claude CLI timer. |
384
+ | Configuration rejects credential options | Do not combine `credentialRole` with any low-level credential option. The exact role-conflict error is: `` `credentialRole` cannot be combined with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials` ``. Low-level options remain accepted alone; `credentialManagement` still cannot be combined with deprecated refresh/writeback options. |
299
385
  | `Claude Code client version is unavailable` | No `ANTHROPIC_CLI_VERSION`, no `claude` binary, and `registry.npmjs.org` was unreachable. Set the variable or allow registry access. |
300
386
  | Cursor generation fails | Node.js 22 or later is required. Generation uses the unpublished protocol through a private Node child, not `cursor-agent`. Protocol drift fails that provider; there is no implicit fallback. |
301
387
  | Command Code generation fails | The client version could not be resolved: set `COMMAND_CODE_CLI_VERSION`, install `command-code`, or allow access to `registry.npmjs.org`. Request metadata includes Node.js version, platform, architecture, and the absolute working directory. |
302
388
  | Ollama missing from `opencode models ollama` | Start a daemon you trust at `ollamaBaseURL` (or the default `localhost:11434`), then `/connect` so the exact session marker can be stored. Confirm any path prefix reaches Ollama's `/api/*` routes. Cloud tags are anonymous catalog entries; `OLLAMA_HOST`, API keys, credential headers, redirects, and direct Cloud generation are not used. |
389
+ | Per-request xAI calls fail with `XaiAccessUnavailableError` | Confirm `xaiOAuth.mode` is `"authority"` on the host `connectorServer` entry and `"consumer"` on the `opencode-ext-connector/xai` subpath entry, the OpenCode auth record holds exactly `{"type":"api","key":"cli-session:xai"}`, and the access file at `${XDG_DATA_HOME}/opencode/xai-access.json` (or `${HOME}/.local/share/opencode/xai-access.json` on Linux when `XDG_DATA_HOME` is unset or empty) is a current-user regular single-link file with mode `0600`, valid closed v1, and a future `expires`. The error is raised before any network call. |
390
+ | Host xAI authority helper never runs | Verify `${HOME}/.local/bin/opensandbox-xai-auth-sync` exists, is executable, and exits `0` on success. The observer treats every non-zero exit as transient and retries after `5000` ms without removing the xAI provider. The helper delegates projection to the OpenSandbox manager. A relative `XDG_DATA_HOME` (set but not absolute) disables the authority without reading or invoking. |
303
391
  | Host credentials exist but the guest provider is disconnected | Check the mount target and permissions, the guest's `HOME`, `CLAUDE_CONFIG_DIR`, injected secret environment, writable guest OpenCode `auth.json`, and whether `/connect` completed inside the guest. |
304
392
  | Ollama works on the host but not in the guest | Guest `localhost` is usually not the host. Check `host.docker.internal` resolution, Linux `host-gateway` mapping, the daemon bind address, firewall and sandbox egress, and whether any base path prefix reaches Ollama's `/api/*` routes. |
305
393
  | One provider is down | Failures are isolated. Transient snapshot failures keep the last-known catalog; an unavailable snapshot removes only that connector-owned provider. |
@@ -2,6 +2,11 @@ import { z } from "zod";
2
2
  import type { HealthPolicy } from "./health.js";
3
3
  export type CredentialRefreshMode = "auto" | "never";
4
4
  export type CredentialManagement = "connector" | "external";
5
+ export type CredentialRole = "owner" | "reader";
6
+ export type XaiOAuthMode = "authority" | "consumer";
7
+ export type XaiOAuthOptions = {
8
+ readonly mode: XaiOAuthMode;
9
+ };
5
10
  export type CredentialRefreshPolicy = {
6
11
  readonly mode: CredentialRefreshMode;
7
12
  readonly leadMs: number;
@@ -17,6 +22,8 @@ export type CredentialAuthority = {
17
22
  export type ConnectorOptionsInput = {
18
23
  readonly providers?: readonly ("claude" | "cursor" | "command-code" | "ollama")[] | undefined;
19
24
  readonly snapshotTimeoutMs?: number | undefined;
25
+ readonly credentialRole?: CredentialRole | undefined;
26
+ readonly xaiOAuth?: XaiOAuthOptions | undefined;
20
27
  readonly credentialManagement?: CredentialManagement | undefined;
21
28
  readonly credentialAuthority?: {
22
29
  readonly claudeCli: {
@@ -44,6 +51,7 @@ export type ConnectorOptions = {
44
51
  readonly writeBackCredentials: boolean;
45
52
  readonly credentialRefresh: CredentialRefreshPolicy;
46
53
  readonly credentialAuthority: CredentialAuthority;
54
+ readonly xaiOAuth: XaiOAuthOptions | null;
47
55
  readonly catalogReloadMs: number;
48
56
  readonly health: HealthPolicy;
49
57
  };
@@ -5,6 +5,11 @@ const NonNegativeSafeIntegerSchema = z.number().int().nonnegative().max(MaximumT
5
5
  const ProviderSchema = z.enum(["claude", "cursor", "command-code", "ollama"]);
6
6
  const CredentialRefreshModeSchema = z.enum(["auto", "never"]);
7
7
  const CredentialManagementSchema = z.enum(["connector", "external"]);
8
+ const CredentialRoleSchema = z.enum(["owner", "reader"]);
9
+ const XaiOAuthSchema = z
10
+ .object({ mode: z.enum(["authority", "consumer"]) })
11
+ .strict()
12
+ .readonly();
8
13
  const SafeIntegerSchema = z.number().int().safe();
9
14
  const DefaultProviders = [
10
15
  "claude",
@@ -26,7 +31,8 @@ const CredentialAuthorityInputSchema = z
26
31
  .strict()
27
32
  .readonly();
28
33
  function resolveCredentialOptions(input) {
29
- switch (input.credentialManagement) {
34
+ const credentialManagement = input.credentialRole === undefined ? input.credentialManagement : "external";
35
+ switch (credentialManagement) {
30
36
  case undefined:
31
37
  return {
32
38
  credentialRefresh: Object.freeze({
@@ -51,6 +57,8 @@ const ConnectorOptionsInputSchema = z
51
57
  .object({
52
58
  providers: z.array(ProviderSchema).optional(),
53
59
  snapshotTimeoutMs: PositiveSafeIntegerSchema.optional(),
60
+ credentialRole: CredentialRoleSchema.optional(),
61
+ xaiOAuth: XaiOAuthSchema.optional(),
54
62
  credentialManagement: CredentialManagementSchema.optional(),
55
63
  credentialAuthority: CredentialAuthorityInputSchema.optional(),
56
64
  writeBackCredentials: z.boolean().optional(),
@@ -72,6 +80,17 @@ const ConnectorOptionsInputSchema = z
72
80
  })
73
81
  .strict()
74
82
  .superRefine((input, context) => {
83
+ if (input.credentialRole !== undefined &&
84
+ (input.credentialManagement !== undefined ||
85
+ input.credentialAuthority !== undefined ||
86
+ input.credentialRefresh !== undefined ||
87
+ input.writeBackCredentials !== undefined)) {
88
+ context.addIssue({
89
+ code: "custom",
90
+ path: ["credentialRole"],
91
+ message: "`credentialRole` cannot be combined with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials`",
92
+ });
93
+ }
75
94
  if (input.credentialManagement !== undefined &&
76
95
  (input.credentialRefresh !== undefined || input.writeBackCredentials !== undefined)) {
77
96
  context.addIssue({
@@ -89,6 +108,14 @@ const ConnectorOptionsInputSchema = z
89
108
  message: "Claude CLI credential authority requires external credential management and the Claude provider",
90
109
  });
91
110
  }
111
+ if (input.credentialRole === "owner" &&
112
+ !(input.providers ?? DefaultProviders).includes("claude")) {
113
+ context.addIssue({
114
+ code: "custom",
115
+ path: ["credentialRole"],
116
+ message: "Credential owner role requires the Claude provider",
117
+ });
118
+ }
92
119
  const initialBackoffMs = input.health?.initialBackoffMs ?? 1_000;
93
120
  const maximumBackoffMs = input.health?.maximumBackoffMs ?? 60_000;
94
121
  if (initialBackoffMs > maximumBackoffMs) {
@@ -103,17 +130,21 @@ export const ConnectorOptionsSchema = ConnectorOptionsInputSchema.transform((inp
103
130
  const credentialOptions = resolveCredentialOptions(input);
104
131
  const credentialAuthority = Object.freeze({
105
132
  claudeCli: Object.freeze({
106
- enabled: input.credentialAuthority?.claudeCli.enabled ?? false,
133
+ enabled: input.credentialRole === "owner"
134
+ ? true
135
+ : (input.credentialAuthority?.claudeCli.enabled ?? false),
107
136
  leadMs: input.credentialAuthority?.claudeCli.leadMs ?? 300_000,
108
137
  retryMs: input.credentialAuthority?.claudeCli.retryMs ?? 300_000,
109
138
  }),
110
139
  });
140
+ const xaiOAuth = input.xaiOAuth === undefined ? null : Object.freeze(input.xaiOAuth);
111
141
  return Object.freeze({
112
142
  providers: Object.freeze(input.providers ?? DefaultProviders),
113
143
  snapshotTimeoutMs: input.snapshotTimeoutMs ?? 30_000,
114
144
  writeBackCredentials: credentialOptions.writeBackCredentials,
115
145
  credentialRefresh: credentialOptions.credentialRefresh,
116
146
  credentialAuthority,
147
+ xaiOAuth,
117
148
  catalogReloadMs: input.catalogReloadMs ?? 300_000,
118
149
  health,
119
150
  });
@@ -3,6 +3,7 @@ export type ProcessCommand = {
3
3
  readonly executable: string;
4
4
  readonly arguments: readonly string[];
5
5
  readonly cwd: string | null;
6
+ readonly environment?: Readonly<Record<string, string>>;
6
7
  };
7
8
  export type ProcessExit = {
8
9
  readonly kind: "code";
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export declare const claudeAuthServer: V1Plugin;
4
4
  export declare const cursorAuthServer: V1Plugin;
5
5
  export declare const commandCodeAuthServer: V1Plugin;
6
6
  export declare const ollamaAuthServer: V1Plugin;
7
+ export declare const xaiAuthServer: V1Plugin;
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export const claudeAuthServer = async (input, options) => (await import("./serve
3
3
  export const cursorAuthServer = async (input, options) => (await import("./server.js")).cursorAuthServer(input, options);
4
4
  export const commandCodeAuthServer = async (input, options) => (await import("./server.js")).commandCodeAuthServer(input, options);
5
5
  export const ollamaAuthServer = async (input, options) => (await import("./server.js")).ollamaAuthServer(input, options);
6
+ export const xaiAuthServer = async (input, options) => (await import("./server.js")).xaiAuthServer(input, options);
@@ -1,8 +1,10 @@
1
1
  import type { ConnectorOptionsInput } from "../core/options.js";
2
- type HostConnectorOptionsInput = Omit<ConnectorOptionsInput, "credentialAuthority" | "credentialManagement" | "credentialRefresh" | "writeBackCredentials"> & {
2
+ type HostConnectorOptionsInput = Omit<ConnectorOptionsInput, "credentialAuthority" | "credentialManagement" | "credentialRefresh" | "credentialRole" | "xaiOAuth" | "writeBackCredentials"> & {
3
3
  readonly credentialAuthority?: unknown;
4
4
  readonly credentialManagement?: unknown;
5
5
  readonly credentialRefresh?: unknown;
6
+ readonly credentialRole?: unknown;
7
+ readonly xaiOAuth?: unknown;
6
8
  readonly writeBackCredentials?: unknown;
7
9
  };
8
10
  export declare function pickConnectorOptionsInput(input: unknown): HostConnectorOptionsInput;
@@ -28,8 +28,10 @@ export function pickConnectorOptionsInput(input) {
28
28
  if (typeof input !== "object" || input === null) {
29
29
  return {};
30
30
  }
31
- const preservesCredentialPolicy = "credentialManagement" in input &&
32
- (input.credentialManagement === "connector" || input.credentialManagement === "external");
31
+ const preservesCredentialPolicy = ("credentialManagement" in input &&
32
+ (input.credentialManagement === "connector" || input.credentialManagement === "external")) ||
33
+ ("credentialRole" in input &&
34
+ (input.credentialRole === "owner" || input.credentialRole === "reader"));
33
35
  const writeBackCredentials = "writeBackCredentials" in input && input.writeBackCredentials !== undefined
34
36
  ? preservesCredentialPolicy
35
37
  ? input.writeBackCredentials
@@ -51,9 +53,13 @@ export function pickConnectorOptionsInput(input) {
51
53
  : undefined,
52
54
  snapshotTimeoutMs: "snapshotTimeoutMs" in input ? positiveInteger(input.snapshotTimeoutMs) : undefined,
53
55
  ...(writeBackCredentials === undefined ? {} : { writeBackCredentials }),
56
+ ...(!("credentialRole" in input) || input.credentialRole === undefined
57
+ ? {}
58
+ : { credentialRole: input.credentialRole }),
54
59
  ...(!("credentialManagement" in input) || input.credentialManagement === undefined
55
60
  ? {}
56
61
  : { credentialManagement: input.credentialManagement }),
62
+ ...(!("xaiOAuth" in input) || input.xaiOAuth === undefined ? {} : { xaiOAuth: input.xaiOAuth }),
57
63
  ...(!("credentialAuthority" in input) || input.credentialAuthority === undefined
58
64
  ? {}
59
65
  : { credentialAuthority: input.credentialAuthority }),
@@ -3,6 +3,7 @@ export type ProcessSpawnOptions = {
3
3
  readonly shell: false;
4
4
  readonly stdio: "ignore";
5
5
  readonly windowsHide: true;
6
+ readonly env?: Readonly<Record<string, string>>;
6
7
  };
7
8
  export interface SpawnedChild {
8
9
  readonly exitCode: number | null;
@@ -12,6 +12,7 @@ function spawnNodeChild(command, options) {
12
12
  shell: options.shell,
13
13
  stdio: options.stdio,
14
14
  windowsHide: options.windowsHide,
15
+ ...(options.env === undefined ? {} : { env: options.env }),
15
16
  });
16
17
  return {
17
18
  get exitCode() {
@@ -136,7 +137,9 @@ export function createProductionProcessSupervisor(options = {}) {
136
137
  throw new ResourceDisposedError("process-supervisor");
137
138
  let child;
138
139
  try {
139
- child = spawnProcess(command, spawnOptions);
140
+ child = spawnProcess(command, command.environment === undefined
141
+ ? spawnOptions
142
+ : { ...spawnOptions, env: command.environment });
140
143
  }
141
144
  catch (error) {
142
145
  throw processFailure("spawn", error);
@@ -0,0 +1,25 @@
1
+ export type XaiAccessState = {
2
+ readonly kind: "ready";
3
+ readonly access: string;
4
+ readonly expires: number;
5
+ } | {
6
+ readonly kind: "unavailable";
7
+ };
8
+ export type XaiAccessFileMetadata = {
9
+ readonly regular: boolean;
10
+ readonly links: number;
11
+ readonly mode: number;
12
+ readonly ownerUid: number;
13
+ };
14
+ export interface XaiAccessFile {
15
+ stat(): Promise<XaiAccessFileMetadata>;
16
+ readText(): Promise<string>;
17
+ close(): Promise<void>;
18
+ }
19
+ export type XaiAccessStateOptions = {
20
+ readonly env: Readonly<Record<string, string | undefined>>;
21
+ readonly currentUid?: () => number | undefined;
22
+ readonly openFile?: (path: string) => Promise<XaiAccessFile>;
23
+ };
24
+ export declare function resolveXaiAccessPath(env: Readonly<Record<string, string | undefined>>): string | null;
25
+ export declare function readXaiAccessState(options: XaiAccessStateOptions): Promise<XaiAccessState>;