opencode-ext-connector 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +64 -5
- package/dist/core/credential-authority-options.d.ts +18 -0
- package/dist/core/credential-authority-options.js +25 -0
- package/dist/core/options.d.ts +9 -15
- package/dist/core/options.js +10 -24
- package/dist/core/process.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/opencode/host-options.d.ts +2 -1
- package/dist/opencode/host-options.js +1 -0
- package/dist/process/production-supervisor.d.ts +1 -0
- package/dist/process/production-supervisor.js +4 -1
- package/dist/providers/claude/credential-authority-scheduler.js +13 -0
- package/dist/providers/xai/access-state.d.ts +25 -0
- package/dist/providers/xai/access-state.js +118 -0
- package/dist/providers/xai/authority-observer.d.ts +13 -0
- package/dist/providers/xai/authority-observer.js +165 -0
- package/dist/providers/xai/consumer-auth.d.ts +23 -0
- package/dist/providers/xai/consumer-auth.js +52 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +17 -2
- package/dist/xai.d.ts +2 -0
- package/dist/xai.js +1 -0
- package/docs/README.ko.md +64 -5
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 - 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Restrict the `credentialAuthority` schema to a strict, deeply-frozen Claude CLI
|
|
6
|
+
object: unknown keys fail parsing, empty input normalizes to disabled defaults
|
|
7
|
+
with `leadMs: 300_000` and `retryMs: 300_000`, and only `claudeCli` is accepted
|
|
8
|
+
- Strip `ANTHROPIC_API_KEY` from every Claude authority child invocation so the
|
|
9
|
+
stored OAuth login is always used regardless of any in-memory key, without
|
|
10
|
+
mutating the parent environment
|
|
11
|
+
- Attempt every disposer on shutdown through `Promise.allSettled` and keep the
|
|
12
|
+
Claude authority failure as the primary error so the xAI authority, supervisor,
|
|
13
|
+
and runtime disposers still run alongside Claude while Claude stays primary
|
|
14
|
+
|
|
15
|
+
## 0.7.0 - 2026-09-23
|
|
16
|
+
|
|
17
|
+
- Add the opt-in `xaiOAuth.mode: "authority" | "consumer"` package subpath
|
|
18
|
+
pairing a host-side authority with a guest-side consumer; omitting
|
|
19
|
+
`xaiOAuth` disables the integration silently, while a present `xaiOAuth`
|
|
20
|
+
whose `mode` is missing or unknown is rejected at parse time
|
|
21
|
+
- Ship the dedicated `./xai` consumer as a separate OpenCode plugin entry
|
|
22
|
+
that returns the xAI auth hook only with `methods: []`
|
|
23
|
+
- Invoke the fixed helper at `${HOME}/.local/bin/opensandbox-xai-auth-sync`
|
|
24
|
+
with no arguments, `PATH=/usr/local/bin:/usr/bin:/bin`, `HOME` always
|
|
25
|
+
set, and `XDG_DATA_HOME` forwarded only when absolute; the helper
|
|
26
|
+
delegates projection to the OpenSandbox manager, and a non-zero exit
|
|
27
|
+
retries after `5000` ms without removing the xAI provider
|
|
28
|
+
- Re-read the access file and `expires` on every outbound request and raise
|
|
29
|
+
`XaiAccessUnavailableError` before any network call on a missing file,
|
|
30
|
+
a stat that fails the closed-v1 `0600`/single-link/current-user gates,
|
|
31
|
+
a schema mismatch, a `"state": "unavailable"` record, malformed JSON,
|
|
32
|
+
or a non-future `expires`
|
|
33
|
+
- Never place a refresh token inside the guest; refresh stays on the host
|
|
34
|
+
authority only
|
|
35
|
+
- Keep existing Claude, Cursor, Command Code, and Ollama providers and
|
|
36
|
+
omitted-mode behavior unchanged
|
|
37
|
+
|
|
3
38
|
## 0.6.0 - 2026-09-16
|
|
4
39
|
|
|
5
40
|
- Add the recommended `credentialRole: "owner" | "reader"` option for shared
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
## Status
|
|
21
21
|
|
|
22
|
-
> Independent unofficial community plugin, version **0.
|
|
22
|
+
> Independent unofficial community plugin, version **0.7.1**. 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.
|
|
58
|
+
"plugin": ["opencode-ext-connector@0.7.1"]
|
|
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
|
|
|
@@ -78,6 +78,7 @@ Omitted `providers` enables all four. An explicit list is a strict allow-list. E
|
|
|
78
78
|
| `writeBackCredentials` | `false` | **Deprecated:** accepted alone for one migration cycle; controls Claude writeback after refresh |
|
|
79
79
|
| `credentialRefresh.mode` | `"auto"` | **Deprecated:** accepted alone for one migration cycle; controls Claude `"auto"` or `"never"` refresh behavior |
|
|
80
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. |
|
|
81
82
|
| `credentialAuthority.claudeCli.enabled` | `false` | Claude-only opt-in: see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only) |
|
|
82
83
|
| `credentialAuthority.claudeCli.leadMs` | `300000` | Claude-only opt-in: milliseconds before credential expiry at which the timer invokes `claude`; non-negative integer |
|
|
83
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 |
|
|
@@ -108,7 +109,7 @@ Choose `"reader"` on every instance that only consumes synchronized credentials:
|
|
|
108
109
|
|
|
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.
|
|
110
111
|
|
|
111
|
-
Set exactly one owner per shared Claude login. Owner mode requires Linux, util-linux `flock`, Claude Code `2.1.
|
|
112
|
+
Set exactly one owner per shared Claude login. Owner mode requires Linux, util-linux `flock`, Claude Code `2.1.265` 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`.
|
|
112
113
|
|
|
113
114
|
Do not combine `credentialRole` with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials`. Existing low-level configurations remain supported for advanced control.
|
|
114
115
|
|
|
@@ -203,7 +204,7 @@ Low-level equivalent:
|
|
|
203
204
|
}
|
|
204
205
|
```
|
|
205
206
|
|
|
206
|
-
After saving the configuration, fully restart OpenCode. The timer requires Linux, util-linux `flock`, Claude Code `2.1.
|
|
207
|
+
After saving the configuration, fully restart OpenCode. The timer requires Linux, util-linux `flock`, Claude Code `2.1.265` or later on `PATH`, an authenticated Claude Code session, and a writable persistent state directory. It is silently disabled on other platforms.
|
|
207
208
|
|
|
208
209
|
`leadMs` and `retryMs` both default to `300000` (5 minutes). The connector schedules one restricted, single-turn Claude request at the lead boundary. A process-shared non-blocking lock prevents concurrent requests from processes using the same state directory; failures retry after `retryMs` without removing the Claude provider.
|
|
209
210
|
|
|
@@ -211,6 +212,58 @@ Each invocation is a real model request and can count against the account's subs
|
|
|
211
212
|
|
|
212
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.
|
|
213
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
|
+
|
|
214
267
|
## Host/Guest Sandbox Setup
|
|
215
268
|
|
|
216
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.
|
|
@@ -223,6 +276,7 @@ The safest shared-session layout keeps each vendor login owned and refreshed by
|
|
|
223
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 |
|
|
224
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 |
|
|
225
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 |
|
|
226
280
|
|
|
227
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:
|
|
228
282
|
|
|
@@ -311,11 +365,14 @@ Ollama `/connect` probes the configured daemon and stores the exact session mark
|
|
|
311
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`. |
|
|
312
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`. |
|
|
313
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). |
|
|
314
369
|
|
|
315
370
|
Provider health is isolated: one provider failing does not remove the others.
|
|
316
371
|
|
|
317
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.
|
|
318
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
|
+
|
|
319
376
|
## Troubleshooting
|
|
320
377
|
|
|
321
378
|
| Symptom | What to check |
|
|
@@ -329,6 +386,8 @@ The standalone SDK entry is `opencode-ext-connector/ollama`; pass `{ ollamaBaseU
|
|
|
329
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. |
|
|
330
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. |
|
|
331
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. |
|
|
332
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. |
|
|
333
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. |
|
|
334
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. |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export type ClaudeCliCredentialAuthority = {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
readonly leadMs: number;
|
|
5
|
+
readonly retryMs: number;
|
|
6
|
+
};
|
|
7
|
+
export type CredentialAuthority = {
|
|
8
|
+
readonly claudeCli: ClaudeCliCredentialAuthority;
|
|
9
|
+
};
|
|
10
|
+
export type CredentialAuthorityInput = {
|
|
11
|
+
readonly claudeCli?: {
|
|
12
|
+
readonly enabled: boolean;
|
|
13
|
+
readonly leadMs?: number | undefined;
|
|
14
|
+
readonly retryMs?: number | undefined;
|
|
15
|
+
} | undefined;
|
|
16
|
+
};
|
|
17
|
+
export declare const CredentialAuthorityInputSchema: z.ZodType<CredentialAuthorityInput, CredentialAuthorityInput>;
|
|
18
|
+
export declare function resolveCredentialAuthority(input: CredentialAuthorityInput | undefined, claudeOwner: boolean): CredentialAuthority;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const SafeIntegerSchema = z.number().int().safe();
|
|
3
|
+
export const CredentialAuthorityInputSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
claudeCli: z
|
|
6
|
+
.object({
|
|
7
|
+
enabled: z.boolean(),
|
|
8
|
+
leadMs: SafeIntegerSchema.nonnegative().optional(),
|
|
9
|
+
retryMs: SafeIntegerSchema.positive().optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict()
|
|
12
|
+
.readonly()
|
|
13
|
+
.optional(),
|
|
14
|
+
})
|
|
15
|
+
.strict()
|
|
16
|
+
.readonly();
|
|
17
|
+
export function resolveCredentialAuthority(input, claudeOwner) {
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
claudeCli: Object.freeze({
|
|
20
|
+
enabled: claudeOwner || (input?.claudeCli?.enabled ?? false),
|
|
21
|
+
leadMs: input?.claudeCli?.leadMs ?? 300_000,
|
|
22
|
+
retryMs: input?.claudeCli?.retryMs ?? 300_000,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}
|
package/dist/core/options.d.ts
CHANGED
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { type CredentialAuthority, type CredentialAuthorityInput } from "./credential-authority-options.js";
|
|
2
3
|
import type { HealthPolicy } from "./health.js";
|
|
4
|
+
export type { ClaudeCliCredentialAuthority, CredentialAuthority, } from "./credential-authority-options.js";
|
|
3
5
|
export type CredentialRefreshMode = "auto" | "never";
|
|
4
6
|
export type CredentialManagement = "connector" | "external";
|
|
5
7
|
export type CredentialRole = "owner" | "reader";
|
|
8
|
+
export type XaiOAuthMode = "authority" | "consumer";
|
|
9
|
+
export type XaiOAuthOptions = {
|
|
10
|
+
readonly mode: XaiOAuthMode;
|
|
11
|
+
};
|
|
6
12
|
export type CredentialRefreshPolicy = {
|
|
7
13
|
readonly mode: CredentialRefreshMode;
|
|
8
14
|
readonly leadMs: number;
|
|
9
15
|
};
|
|
10
|
-
export type ClaudeCliCredentialAuthority = {
|
|
11
|
-
readonly enabled: boolean;
|
|
12
|
-
readonly leadMs: number;
|
|
13
|
-
readonly retryMs: number;
|
|
14
|
-
};
|
|
15
|
-
export type CredentialAuthority = {
|
|
16
|
-
readonly claudeCli: ClaudeCliCredentialAuthority;
|
|
17
|
-
};
|
|
18
16
|
export type ConnectorOptionsInput = {
|
|
19
17
|
readonly providers?: readonly ("claude" | "cursor" | "command-code" | "ollama")[] | undefined;
|
|
20
18
|
readonly snapshotTimeoutMs?: number | undefined;
|
|
21
19
|
readonly credentialRole?: CredentialRole | undefined;
|
|
20
|
+
readonly xaiOAuth?: XaiOAuthOptions | undefined;
|
|
22
21
|
readonly credentialManagement?: CredentialManagement | undefined;
|
|
23
|
-
readonly credentialAuthority?:
|
|
24
|
-
readonly claudeCli: {
|
|
25
|
-
readonly enabled: boolean;
|
|
26
|
-
readonly leadMs?: number | undefined;
|
|
27
|
-
readonly retryMs?: number | undefined;
|
|
28
|
-
};
|
|
29
|
-
} | undefined;
|
|
22
|
+
readonly credentialAuthority?: CredentialAuthorityInput | undefined;
|
|
30
23
|
/** @deprecated Use credentialManagement instead. */
|
|
31
24
|
readonly writeBackCredentials?: boolean | undefined;
|
|
32
25
|
/** @deprecated Use credentialManagement instead. */
|
|
@@ -46,6 +39,7 @@ export type ConnectorOptions = {
|
|
|
46
39
|
readonly writeBackCredentials: boolean;
|
|
47
40
|
readonly credentialRefresh: CredentialRefreshPolicy;
|
|
48
41
|
readonly credentialAuthority: CredentialAuthority;
|
|
42
|
+
readonly xaiOAuth: XaiOAuthOptions | null;
|
|
49
43
|
readonly catalogReloadMs: number;
|
|
50
44
|
readonly health: HealthPolicy;
|
|
51
45
|
};
|
package/dist/core/options.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { CredentialAuthorityInputSchema, resolveCredentialAuthority, } from "./credential-authority-options.js";
|
|
2
3
|
const MaximumTimerMs = 2_147_483_647;
|
|
3
4
|
const PositiveSafeIntegerSchema = z.number().int().positive().max(MaximumTimerMs);
|
|
4
5
|
const NonNegativeSafeIntegerSchema = z.number().int().nonnegative().max(MaximumTimerMs);
|
|
@@ -6,26 +7,16 @@ const ProviderSchema = z.enum(["claude", "cursor", "command-code", "ollama"]);
|
|
|
6
7
|
const CredentialRefreshModeSchema = z.enum(["auto", "never"]);
|
|
7
8
|
const CredentialManagementSchema = z.enum(["connector", "external"]);
|
|
8
9
|
const CredentialRoleSchema = z.enum(["owner", "reader"]);
|
|
9
|
-
const
|
|
10
|
+
const XaiOAuthSchema = z
|
|
11
|
+
.object({ mode: z.enum(["authority", "consumer"]) })
|
|
12
|
+
.strict()
|
|
13
|
+
.readonly();
|
|
10
14
|
const DefaultProviders = [
|
|
11
15
|
"claude",
|
|
12
16
|
"cursor",
|
|
13
17
|
"command-code",
|
|
14
18
|
"ollama",
|
|
15
19
|
];
|
|
16
|
-
const CredentialAuthorityInputSchema = z
|
|
17
|
-
.object({
|
|
18
|
-
claudeCli: z
|
|
19
|
-
.object({
|
|
20
|
-
enabled: z.boolean(),
|
|
21
|
-
leadMs: SafeIntegerSchema.nonnegative().optional(),
|
|
22
|
-
retryMs: SafeIntegerSchema.positive().optional(),
|
|
23
|
-
})
|
|
24
|
-
.strict()
|
|
25
|
-
.readonly(),
|
|
26
|
-
})
|
|
27
|
-
.strict()
|
|
28
|
-
.readonly();
|
|
29
20
|
function resolveCredentialOptions(input) {
|
|
30
21
|
const credentialManagement = input.credentialRole === undefined ? input.credentialManagement : "external";
|
|
31
22
|
switch (credentialManagement) {
|
|
@@ -54,6 +45,7 @@ const ConnectorOptionsInputSchema = z
|
|
|
54
45
|
providers: z.array(ProviderSchema).optional(),
|
|
55
46
|
snapshotTimeoutMs: PositiveSafeIntegerSchema.optional(),
|
|
56
47
|
credentialRole: CredentialRoleSchema.optional(),
|
|
48
|
+
xaiOAuth: XaiOAuthSchema.optional(),
|
|
57
49
|
credentialManagement: CredentialManagementSchema.optional(),
|
|
58
50
|
credentialAuthority: CredentialAuthorityInputSchema.optional(),
|
|
59
51
|
writeBackCredentials: z.boolean().optional(),
|
|
@@ -94,7 +86,7 @@ const ConnectorOptionsInputSchema = z
|
|
|
94
86
|
message: "`credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials`",
|
|
95
87
|
});
|
|
96
88
|
}
|
|
97
|
-
if (input.credentialAuthority?.claudeCli
|
|
89
|
+
if (input.credentialAuthority?.claudeCli?.enabled === true &&
|
|
98
90
|
(input.credentialManagement !== "external" ||
|
|
99
91
|
!(input.providers ?? DefaultProviders).includes("claude"))) {
|
|
100
92
|
context.addIssue({
|
|
@@ -123,21 +115,15 @@ export const ConnectorOptionsSchema = ConnectorOptionsInputSchema.transform((inp
|
|
|
123
115
|
maximumBackoffMs: input.health?.maximumBackoffMs ?? 60_000,
|
|
124
116
|
});
|
|
125
117
|
const credentialOptions = resolveCredentialOptions(input);
|
|
126
|
-
const credentialAuthority =
|
|
127
|
-
|
|
128
|
-
enabled: input.credentialRole === "owner"
|
|
129
|
-
? true
|
|
130
|
-
: (input.credentialAuthority?.claudeCli.enabled ?? false),
|
|
131
|
-
leadMs: input.credentialAuthority?.claudeCli.leadMs ?? 300_000,
|
|
132
|
-
retryMs: input.credentialAuthority?.claudeCli.retryMs ?? 300_000,
|
|
133
|
-
}),
|
|
134
|
-
});
|
|
118
|
+
const credentialAuthority = resolveCredentialAuthority(input.credentialAuthority, input.credentialRole === "owner");
|
|
119
|
+
const xaiOAuth = input.xaiOAuth === undefined ? null : Object.freeze(input.xaiOAuth);
|
|
135
120
|
return Object.freeze({
|
|
136
121
|
providers: Object.freeze(input.providers ?? DefaultProviders),
|
|
137
122
|
snapshotTimeoutMs: input.snapshotTimeoutMs ?? 30_000,
|
|
138
123
|
writeBackCredentials: credentialOptions.writeBackCredentials,
|
|
139
124
|
credentialRefresh: credentialOptions.credentialRefresh,
|
|
140
125
|
credentialAuthority,
|
|
126
|
+
xaiOAuth,
|
|
141
127
|
catalogReloadMs: input.catalogReloadMs ?? 300_000,
|
|
142
128
|
health,
|
|
143
129
|
});
|
package/dist/core/process.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
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,9 +1,10 @@
|
|
|
1
1
|
import type { ConnectorOptionsInput } from "../core/options.js";
|
|
2
|
-
type HostConnectorOptionsInput = Omit<ConnectorOptionsInput, "credentialAuthority" | "credentialManagement" | "credentialRefresh" | "credentialRole" | "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
6
|
readonly credentialRole?: unknown;
|
|
7
|
+
readonly xaiOAuth?: unknown;
|
|
7
8
|
readonly writeBackCredentials?: unknown;
|
|
8
9
|
};
|
|
9
10
|
export declare function pickConnectorOptionsInput(input: unknown): HostConnectorOptionsInput;
|
|
@@ -59,6 +59,7 @@ export function pickConnectorOptionsInput(input) {
|
|
|
59
59
|
...(!("credentialManagement" in input) || input.credentialManagement === undefined
|
|
60
60
|
? {}
|
|
61
61
|
: { credentialManagement: input.credentialManagement }),
|
|
62
|
+
...(!("xaiOAuth" in input) || input.xaiOAuth === undefined ? {} : { xaiOAuth: input.xaiOAuth }),
|
|
62
63
|
...(!("credentialAuthority" in input) || input.credentialAuthority === undefined
|
|
63
64
|
? {}
|
|
64
65
|
: { credentialAuthority: input.credentialAuthority }),
|
|
@@ -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,
|
|
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);
|
|
@@ -8,6 +8,7 @@ const LOCK_FILE = "authority.lock";
|
|
|
8
8
|
const LOCK_CONFLICT_EXIT_CODE = 75;
|
|
9
9
|
const MAXIMUM_DELAY_MS = 2_147_483_647;
|
|
10
10
|
const AUTHORITY_PROMPT = "Reply OK without using tools.";
|
|
11
|
+
const ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY";
|
|
11
12
|
function authorityStateDirectory(env) {
|
|
12
13
|
const configuredState = env.XDG_STATE_HOME;
|
|
13
14
|
if (configuredState !== undefined && configuredState.length > 0 && isAbsolute(configuredState)) {
|
|
@@ -24,6 +25,15 @@ async function createStateDirectory(path) {
|
|
|
24
25
|
function assertNeverProcessExit(exit) {
|
|
25
26
|
throw new InvalidArgumentError("processExit", exit);
|
|
26
27
|
}
|
|
28
|
+
function authorityChildEnvironment(env) {
|
|
29
|
+
const child = {};
|
|
30
|
+
for (const [key, value] of Object.entries(env)) {
|
|
31
|
+
if (key === ANTHROPIC_API_KEY || value === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
child[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze(child);
|
|
36
|
+
}
|
|
27
37
|
export function createClaudeCredentialAuthorityScheduler(options) {
|
|
28
38
|
if (!Number.isSafeInteger(options.leadMs) || options.leadMs < 0) {
|
|
29
39
|
throw new InvalidArgumentError("leadMs");
|
|
@@ -55,6 +65,8 @@ export function createClaudeCredentialAuthorityScheduler(options) {
|
|
|
55
65
|
if (stateDirectory === null)
|
|
56
66
|
return null;
|
|
57
67
|
await ensureStateDirectory(stateDirectory);
|
|
68
|
+
// Use the stored OAuth login even when the parent has an API key.
|
|
69
|
+
const environment = authorityChildEnvironment(options.env);
|
|
58
70
|
const process = await options.processSupervisor.start({
|
|
59
71
|
executable: "flock",
|
|
60
72
|
arguments: [
|
|
@@ -77,6 +89,7 @@ export function createClaudeCredentialAuthorityScheduler(options) {
|
|
|
77
89
|
"json",
|
|
78
90
|
],
|
|
79
91
|
cwd: stateDirectory,
|
|
92
|
+
environment,
|
|
80
93
|
}, controller.signal);
|
|
81
94
|
activeProcess = process;
|
|
82
95
|
try {
|
|
@@ -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>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const XaiAccessRecordSchema = z.discriminatedUnion("state", [
|
|
6
|
+
z
|
|
7
|
+
.object({
|
|
8
|
+
schema_version: z.literal(1),
|
|
9
|
+
provider: z.literal("xai"),
|
|
10
|
+
state: z.literal("ready"),
|
|
11
|
+
access: z.string().min(1),
|
|
12
|
+
expires: z.number().int().nonnegative(),
|
|
13
|
+
})
|
|
14
|
+
.strict()
|
|
15
|
+
.readonly(),
|
|
16
|
+
z
|
|
17
|
+
.object({
|
|
18
|
+
schema_version: z.literal(1),
|
|
19
|
+
provider: z.literal("xai"),
|
|
20
|
+
state: z.literal("unavailable"),
|
|
21
|
+
})
|
|
22
|
+
.strict()
|
|
23
|
+
.readonly(),
|
|
24
|
+
]);
|
|
25
|
+
const UnavailableState = Object.freeze({ kind: "unavailable" });
|
|
26
|
+
class XaiAccessStateInvariantError extends Error {
|
|
27
|
+
name = "XaiAccessStateInvariantError";
|
|
28
|
+
constructor() {
|
|
29
|
+
super("unexpected xAI access state");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertNeverAccessState(_state) {
|
|
33
|
+
throw new XaiAccessStateInvariantError();
|
|
34
|
+
}
|
|
35
|
+
function currentProcessUid() {
|
|
36
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
37
|
+
}
|
|
38
|
+
async function openAccessFile(path) {
|
|
39
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
40
|
+
return {
|
|
41
|
+
stat: async () => {
|
|
42
|
+
const metadata = await handle.stat();
|
|
43
|
+
return {
|
|
44
|
+
regular: metadata.isFile(),
|
|
45
|
+
links: metadata.nlink,
|
|
46
|
+
mode: metadata.mode & 0o7777,
|
|
47
|
+
ownerUid: metadata.uid,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
readText: () => handle.readFile("utf8"),
|
|
51
|
+
close: () => handle.close(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function resolveXaiAccessPath(env) {
|
|
55
|
+
const dataHome = env["XDG_DATA_HOME"];
|
|
56
|
+
if (dataHome !== undefined && dataHome.length > 0) {
|
|
57
|
+
return isAbsolute(dataHome) ? join(dataHome, "opencode", "xai-access.json") : null;
|
|
58
|
+
}
|
|
59
|
+
const home = env["HOME"];
|
|
60
|
+
return home !== undefined && home.length > 0 && isAbsolute(home)
|
|
61
|
+
? join(home, ".local", "share", "opencode", "xai-access.json")
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
function parsedState(raw) {
|
|
65
|
+
const parsedJson = JSON.parse(raw);
|
|
66
|
+
const record = XaiAccessRecordSchema.safeParse(parsedJson);
|
|
67
|
+
if (!record.success)
|
|
68
|
+
return UnavailableState;
|
|
69
|
+
switch (record.data.state) {
|
|
70
|
+
case "ready":
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
kind: "ready",
|
|
73
|
+
access: record.data.access,
|
|
74
|
+
expires: record.data.expires,
|
|
75
|
+
});
|
|
76
|
+
case "unavailable":
|
|
77
|
+
return UnavailableState;
|
|
78
|
+
default:
|
|
79
|
+
return assertNeverAccessState(record.data);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function readXaiAccessState(options) {
|
|
83
|
+
const path = resolveXaiAccessPath(options.env);
|
|
84
|
+
if (path === null)
|
|
85
|
+
return UnavailableState;
|
|
86
|
+
const openFile = options.openFile ?? openAccessFile;
|
|
87
|
+
let file;
|
|
88
|
+
try {
|
|
89
|
+
file = await openFile(path);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof Error)
|
|
93
|
+
return UnavailableState;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
let state = UnavailableState;
|
|
97
|
+
try {
|
|
98
|
+
const metadata = await file.stat();
|
|
99
|
+
const runtimeUid = (options.currentUid ?? currentProcessUid)();
|
|
100
|
+
const ownerMatches = runtimeUid === undefined || metadata.ownerUid === runtimeUid;
|
|
101
|
+
if (metadata.regular && metadata.links === 1 && metadata.mode === 0o600 && ownerMatches) {
|
|
102
|
+
state = parsedState(await file.readText());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!(error instanceof Error))
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await file.close();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof Error)
|
|
114
|
+
return UnavailableState;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Clock } from "../../core/clock.js";
|
|
2
|
+
import { type AsyncDisposableHandle } from "../../core/lifecycle.js";
|
|
3
|
+
import type { ProcessSupervisor } from "../../core/process.js";
|
|
4
|
+
export type XaiAuthorityObserverOptions = {
|
|
5
|
+
readonly enabled: boolean;
|
|
6
|
+
readonly clock: Clock;
|
|
7
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly processSupervisor: ProcessSupervisor;
|
|
9
|
+
readonly pollMs?: number;
|
|
10
|
+
readonly retryMs?: number;
|
|
11
|
+
readonly readAuthFile?: (path: string) => Promise<string>;
|
|
12
|
+
};
|
|
13
|
+
export declare function createXaiAuthorityObserver(options: XaiAuthorityObserverOptions): AsyncDisposableHandle;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { InvalidArgumentError, OperationCancelledError } from "../../core/errors.js";
|
|
5
|
+
import { createAsyncDisposable } from "../../core/lifecycle.js";
|
|
6
|
+
import { opencodeAuthJsonPaths } from "../../opencode/auth-store.js";
|
|
7
|
+
const AUTH_SYNC_HELPER = "opensandbox-xai-auth-sync";
|
|
8
|
+
const AUTH_SYNC_PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
9
|
+
const AuthRootSchema = z.record(z.string(), z.unknown());
|
|
10
|
+
function errorCode(error) {
|
|
11
|
+
return "code" in error ? Reflect.get(error, "code") : undefined;
|
|
12
|
+
}
|
|
13
|
+
function commandEnvironment(env, home) {
|
|
14
|
+
const dataHome = env["XDG_DATA_HOME"];
|
|
15
|
+
return dataHome === undefined || dataHome.length === 0 || !isAbsolute(dataHome)
|
|
16
|
+
? Object.freeze({ HOME: home, PATH: AUTH_SYNC_PATH })
|
|
17
|
+
: Object.freeze({ HOME: home, PATH: AUTH_SYNC_PATH, XDG_DATA_HOME: dataHome });
|
|
18
|
+
}
|
|
19
|
+
class XaiAuthorityInvariantError extends Error {
|
|
20
|
+
name = "XaiAuthorityInvariantError";
|
|
21
|
+
constructor() {
|
|
22
|
+
super("unexpected xAI authority state");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function assertNeverAuthorityState(_state) {
|
|
26
|
+
throw new XaiAuthorityInvariantError();
|
|
27
|
+
}
|
|
28
|
+
export function createXaiAuthorityObserver(options) {
|
|
29
|
+
const pollMs = options.pollMs ?? 1_000;
|
|
30
|
+
const retryMs = options.retryMs ?? 5_000;
|
|
31
|
+
if (!Number.isSafeInteger(pollMs) || pollMs <= 0)
|
|
32
|
+
throw new InvalidArgumentError("pollMs");
|
|
33
|
+
if (!Number.isSafeInteger(retryMs) || retryMs <= 0)
|
|
34
|
+
throw new InvalidArgumentError("retryMs");
|
|
35
|
+
if (!options.enabled)
|
|
36
|
+
return createAsyncDisposable(() => undefined);
|
|
37
|
+
const home = options.env["HOME"];
|
|
38
|
+
if (home === undefined || home.length === 0 || !isAbsolute(home)) {
|
|
39
|
+
return createAsyncDisposable(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
const dataHome = options.env["XDG_DATA_HOME"];
|
|
42
|
+
if (dataHome !== undefined && dataHome.length > 0 && !isAbsolute(dataHome)) {
|
|
43
|
+
return createAsyncDisposable(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
const [authPath] = opencodeAuthJsonPaths(options.env);
|
|
46
|
+
if (authPath === undefined || !isAbsolute(authPath))
|
|
47
|
+
return createAsyncDisposable(() => undefined);
|
|
48
|
+
const helper = join(home, ".local", "bin", AUTH_SYNC_HELPER);
|
|
49
|
+
const environment = commandEnvironment(options.env, home);
|
|
50
|
+
const loadAuth = options.readAuthFile ?? ((path) => readFile(path, "utf8"));
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
let scheduled;
|
|
53
|
+
let activeProcess;
|
|
54
|
+
let activeEvaluation;
|
|
55
|
+
let synchronizedFingerprint;
|
|
56
|
+
let disposalStarted = false;
|
|
57
|
+
const arm = (delayMs) => {
|
|
58
|
+
if (disposalStarted)
|
|
59
|
+
return;
|
|
60
|
+
scheduled?.cancel();
|
|
61
|
+
scheduled = options.clock.schedule(delayMs, () => {
|
|
62
|
+
scheduled = undefined;
|
|
63
|
+
startEvaluation();
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const observe = async () => {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = await loadAuth(authPath);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof Error && errorCode(error) === "ENOENT") {
|
|
73
|
+
return { kind: "ready", fingerprint: "null" };
|
|
74
|
+
}
|
|
75
|
+
if (error instanceof Error)
|
|
76
|
+
return { kind: "retry" };
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsedJson = JSON.parse(raw);
|
|
81
|
+
const root = AuthRootSchema.safeParse(parsedJson);
|
|
82
|
+
return root.success
|
|
83
|
+
? { kind: "ready", fingerprint: JSON.stringify(root.data["xai"] ?? null) }
|
|
84
|
+
: { kind: "retry" };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof SyntaxError)
|
|
88
|
+
return { kind: "retry" };
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const invoke = async () => {
|
|
93
|
+
const process = await options.processSupervisor.start({ executable: helper, arguments: [], cwd: null, environment }, controller.signal);
|
|
94
|
+
activeProcess = process;
|
|
95
|
+
try {
|
|
96
|
+
const exit = await process.wait(controller.signal);
|
|
97
|
+
switch (exit.kind) {
|
|
98
|
+
case "code":
|
|
99
|
+
return exit.code === 0;
|
|
100
|
+
case "signal":
|
|
101
|
+
return false;
|
|
102
|
+
default:
|
|
103
|
+
return assertNeverAuthorityState(exit);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
activeProcess = undefined;
|
|
108
|
+
await process.dispose();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const evaluate = async () => {
|
|
112
|
+
const observation = await observe();
|
|
113
|
+
if (disposalStarted)
|
|
114
|
+
return;
|
|
115
|
+
switch (observation.kind) {
|
|
116
|
+
case "retry":
|
|
117
|
+
arm(retryMs);
|
|
118
|
+
return;
|
|
119
|
+
case "ready":
|
|
120
|
+
if (observation.fingerprint === synchronizedFingerprint) {
|
|
121
|
+
arm(pollMs);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (await invoke())
|
|
125
|
+
synchronizedFingerprint = observation.fingerprint;
|
|
126
|
+
if (!disposalStarted) {
|
|
127
|
+
arm(observation.fingerprint === synchronizedFingerprint ? pollMs : retryMs);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
default:
|
|
131
|
+
return assertNeverAuthorityState(observation);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const startEvaluation = () => {
|
|
135
|
+
if (disposalStarted || activeEvaluation !== undefined)
|
|
136
|
+
return;
|
|
137
|
+
const operation = evaluate().catch((error) => {
|
|
138
|
+
if (error instanceof OperationCancelledError && disposalStarted)
|
|
139
|
+
return;
|
|
140
|
+
if (error instanceof Error) {
|
|
141
|
+
arm(retryMs);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
});
|
|
146
|
+
activeEvaluation = operation;
|
|
147
|
+
void operation.then(() => {
|
|
148
|
+
if (activeEvaluation === operation)
|
|
149
|
+
activeEvaluation = undefined;
|
|
150
|
+
}, () => {
|
|
151
|
+
if (activeEvaluation === operation)
|
|
152
|
+
activeEvaluation = undefined;
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
const disposal = createAsyncDisposable(async () => {
|
|
156
|
+
disposalStarted = true;
|
|
157
|
+
scheduled?.cancel();
|
|
158
|
+
scheduled = undefined;
|
|
159
|
+
controller.abort();
|
|
160
|
+
await activeProcess?.terminate();
|
|
161
|
+
await activeEvaluation;
|
|
162
|
+
});
|
|
163
|
+
startEvaluation();
|
|
164
|
+
return { dispose: disposal.dispose, [Symbol.asyncDispose]: disposal[Symbol.asyncDispose] };
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Clock } from "../../core/clock.js";
|
|
2
|
+
export type NetworkFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
3
|
+
export type XaiConsumerAuthOptions = {
|
|
4
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
readonly clock: Clock;
|
|
6
|
+
readonly networkFetch: NetworkFetch;
|
|
7
|
+
};
|
|
8
|
+
export type XaiConsumerAuthHook = {
|
|
9
|
+
readonly provider: "xai";
|
|
10
|
+
readonly loader: (getAuth: () => Promise<unknown>) => Promise<{
|
|
11
|
+
readonly apiKey: string;
|
|
12
|
+
readonly fetch: NetworkFetch;
|
|
13
|
+
} | {
|
|
14
|
+
readonly apiKey?: never;
|
|
15
|
+
readonly fetch?: never;
|
|
16
|
+
}>;
|
|
17
|
+
readonly methods: [];
|
|
18
|
+
};
|
|
19
|
+
export declare class XaiAccessUnavailableError extends Error {
|
|
20
|
+
readonly name = "XaiAccessUnavailableError";
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
export declare function createXaiConsumerAuth(options: XaiConsumerAuthOptions): XaiConsumerAuthHook;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readXaiAccessState } from "./access-state.js";
|
|
3
|
+
const XAI_CONSUMER_MARKER = "cli-session:xai";
|
|
4
|
+
const XAI_DUMMY_API_KEY = "xai-access-file";
|
|
5
|
+
const XaiConsumerMarkerSchema = z
|
|
6
|
+
.object({ type: z.literal("api"), key: z.literal(XAI_CONSUMER_MARKER) })
|
|
7
|
+
.strict();
|
|
8
|
+
export class XaiAccessUnavailableError extends Error {
|
|
9
|
+
name = "XaiAccessUnavailableError";
|
|
10
|
+
constructor() {
|
|
11
|
+
super("xAI consumer access is unavailable");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function assertNeverAccessState(_state) {
|
|
15
|
+
throw new XaiAccessUnavailableError();
|
|
16
|
+
}
|
|
17
|
+
function createConsumerFetch(options) {
|
|
18
|
+
return async (input, init) => {
|
|
19
|
+
const state = await readXaiAccessState({ env: options.env });
|
|
20
|
+
let access;
|
|
21
|
+
switch (state.kind) {
|
|
22
|
+
case "ready":
|
|
23
|
+
if (state.expires <= options.clock.nowMs())
|
|
24
|
+
throw new XaiAccessUnavailableError();
|
|
25
|
+
access = state.access;
|
|
26
|
+
break;
|
|
27
|
+
case "unavailable":
|
|
28
|
+
throw new XaiAccessUnavailableError();
|
|
29
|
+
default:
|
|
30
|
+
return assertNeverAccessState(state);
|
|
31
|
+
}
|
|
32
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
33
|
+
const initHeaders = new Headers(init?.headers);
|
|
34
|
+
initHeaders.forEach((value, key) => {
|
|
35
|
+
headers.set(key, value);
|
|
36
|
+
});
|
|
37
|
+
headers.set("authorization", `Bearer ${access}`);
|
|
38
|
+
return options.networkFetch(input, init === undefined ? { headers } : { ...init, headers });
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function createXaiConsumerAuth(options) {
|
|
42
|
+
return {
|
|
43
|
+
provider: "xai",
|
|
44
|
+
loader: async (getAuth) => {
|
|
45
|
+
const marker = XaiConsumerMarkerSchema.safeParse(await getAuth());
|
|
46
|
+
return marker.success
|
|
47
|
+
? { apiKey: XAI_DUMMY_API_KEY, fetch: createConsumerFetch(options) }
|
|
48
|
+
: {};
|
|
49
|
+
},
|
|
50
|
+
methods: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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;
|
|
7
8
|
export type ConnectorPluginModule = {
|
|
8
9
|
readonly id: "opencode-ext-connector";
|
|
9
10
|
readonly server: V1Plugin;
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,8 @@ import { createProductionProcessSupervisor } from "./process/production-supervis
|
|
|
14
14
|
import { createClaudeCredentialAuthorityScheduler } from "./providers/claude/credential-authority-scheduler.js";
|
|
15
15
|
import { writeClaudeCredentials } from "./providers/claude/writeback.js";
|
|
16
16
|
import { productionOllamaFetch } from "./providers/ollama/http.js";
|
|
17
|
+
import { createXaiAuthorityObserver } from "./providers/xai/authority-observer.js";
|
|
18
|
+
import { createXaiConsumerAuth } from "./providers/xai/consumer-auth.js";
|
|
17
19
|
const env = process.env;
|
|
18
20
|
const transport = createFetchHttpTransport();
|
|
19
21
|
const authStore = createOpenCodeAuthStore({ env });
|
|
@@ -77,7 +79,7 @@ export const connectorServer = async (input, options) => {
|
|
|
77
79
|
})(input, options);
|
|
78
80
|
const processSupervisor = createProductionProcessSupervisor();
|
|
79
81
|
const claudeCliAuthority = connectorOptions.credentialAuthority.claudeCli;
|
|
80
|
-
const
|
|
82
|
+
const claudeCredentialAuthority = createClaudeCredentialAuthorityScheduler({
|
|
81
83
|
enabled: claudeCliAuthority.enabled,
|
|
82
84
|
clock,
|
|
83
85
|
leadMs: claudeCliAuthority.leadMs,
|
|
@@ -86,10 +88,17 @@ export const connectorServer = async (input, options) => {
|
|
|
86
88
|
processSupervisor,
|
|
87
89
|
logger,
|
|
88
90
|
});
|
|
91
|
+
const xaiAuthority = createXaiAuthorityObserver({
|
|
92
|
+
enabled: connectorOptions.xaiOAuth?.mode === "authority",
|
|
93
|
+
clock,
|
|
94
|
+
env,
|
|
95
|
+
processSupervisor,
|
|
96
|
+
});
|
|
89
97
|
const dispose = hooks.dispose;
|
|
90
98
|
const disposal = createAsyncDisposable(async () => {
|
|
91
99
|
const results = await Promise.allSettled([
|
|
92
|
-
Promise.resolve().then(() =>
|
|
100
|
+
Promise.resolve().then(() => claudeCredentialAuthority.dispose()),
|
|
101
|
+
Promise.resolve().then(() => xaiAuthority.dispose()),
|
|
93
102
|
Promise.resolve().then(() => processSupervisor.dispose()),
|
|
94
103
|
Promise.resolve().then(() => dispose?.()),
|
|
95
104
|
Promise.resolve().then(disposeV1LanguageRuntime),
|
|
@@ -129,6 +138,12 @@ export const ollamaAuthServer = async (_input, options) => {
|
|
|
129
138
|
}).find((candidate) => candidate.id === "ollama");
|
|
130
139
|
return entry === undefined ? {} : buildV1AuthHooks(entry, providerDeps, options);
|
|
131
140
|
};
|
|
141
|
+
export const xaiAuthServer = async (_input, options) => {
|
|
142
|
+
const connectorOptions = parseConnectorOptions(pickConnectorOptionsInput(options));
|
|
143
|
+
return connectorOptions.xaiOAuth?.mode === "consumer"
|
|
144
|
+
? { auth: createXaiConsumerAuth({ env, clock, networkFetch: globalThis.fetch }) }
|
|
145
|
+
: {};
|
|
146
|
+
};
|
|
132
147
|
export const plugin = {
|
|
133
148
|
id: "opencode-ext-connector",
|
|
134
149
|
server: connectorServer,
|
package/dist/xai.d.ts
ADDED
package/dist/xai.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const xaiAuthServer = async (input, options) => (await import("./server.js")).xaiAuthServer(input, options);
|
package/docs/README.ko.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
## 상태
|
|
21
21
|
|
|
22
|
-
> 독립적인 비공식 커뮤니티 플러그인, 버전 **0.
|
|
22
|
+
> 독립적인 비공식 커뮤니티 플러그인, 버전 **0.7.1**. CI에 설치된 OpenCode CLI를 대상으로 legacy multi-function 로더를 패키지 E2E 테스트로 검증합니다. `@opencode-ai/plugin@1.18.18`은 컴파일 시 사용하는 플러그인 API 대상이며 OpenCode 런타임 버전 고정이 아닙니다. 소스는 BSD-3-Clause입니다. 이 프로젝트는 OpenCode 또는 어떤 프로바이더와도 제휴, 보증, 후원, 승인 관계가 없습니다. 전체 조건은 [라이선스 및 면책 조항](#라이선스-및-면책-조항)에 있습니다.
|
|
23
23
|
|
|
24
24
|
이미 가지고 있는 Claude, Cursor, Command Code, Ollama 세션을 재사용합니다. `opencode.json` 플러그인 항목 하나가 라이브 카탈로그를 OpenCode에 공개합니다. Claude와 Cursor는 OpenCode에 마커 또는 OAuth 레코드가 있고 벤더 세션이 있을 때까지 연결되지 않은 상태로 유지됩니다. Command Code는 OpenCode에 저장된 직접 API 키 또는 기존 CLI 세션/키를 사용할 수 있습니다. Ollama는 정확한 세션 마커와 응답하는 신뢰된 데몬이 필요합니다.
|
|
25
25
|
|
|
@@ -55,13 +55,13 @@ OpenCode는 시작 시 Bun으로 설정된 npm 플러그인을 설치하고 캐
|
|
|
55
55
|
```jsonc
|
|
56
56
|
{
|
|
57
57
|
"$schema": "https://opencode.ai/config.json",
|
|
58
|
-
"plugin": ["opencode-ext-connector@0.
|
|
58
|
+
"plugin": ["opencode-ext-connector@0.7.1"]
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
항목을 추가하거나 변경한 뒤 OpenCode를 완전히 종료하고 다시 시작하십시오. 리로드만으로는 충분하지 않습니다.
|
|
63
63
|
|
|
64
|
-
패키지 항목 하나가 카탈로그 플러그인과 Claude, Cursor, Command Code, Ollama 인증 hook을 노출합니다. 프로바이더 id: `claude`, `cursor`, `command-code`, `ollama`. 모델 id는 각 프로바이더의 라이브 카탈로그에서 가져오며, 라이브 목록이 비어 있으면 문서화된 fallback은 Cursor의 `default`와 Command Code의 `Qwen/Qwen3.8-Max`입니다.
|
|
64
|
+
패키지 항목 하나가 카탈로그 플러그인과 Claude, Cursor, Command Code, Ollama 인증 hook을 노출합니다. 프로바이더 id: `claude`, `cursor`, `command-code`, `ollama`. 모델 id는 각 프로바이더의 라이브 카탈로그에서 가져오며, 라이브 목록이 비어 있으면 문서화된 fallback은 Cursor의 `default`와 Command Code의 `Qwen/Qwen3.8-Max`입니다. 옵트인 xAI 컨슈머는 패키지 subpath `opencode-ext-connector/xai`로 노출됩니다. [옵트인 xAI OAuth 권한과 컨슈머](#옵트인-xai-oauth-권한과-컨슈머-호스트와-게스트)를 참조하십시오.
|
|
65
65
|
|
|
66
66
|
## 설정
|
|
67
67
|
|
|
@@ -78,6 +78,7 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
|
|
|
78
78
|
| `writeBackCredentials` | `false` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 갱신 후 Claude writeback을 제어 |
|
|
79
79
|
| `credentialRefresh.mode` | `"auto"` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, Claude의 `"auto"` 또는 `"never"` 갱신 동작을 제어 |
|
|
80
80
|
| `credentialRefresh.leadMs` | `60000` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 사용자 지정 리드 타임에는 여전히 이 레거시 설정이 필요 |
|
|
81
|
+
| `xaiOAuth.mode` | 생략 | 옵트인 xAI 역할. `"authority"`는 호스트 `connectorServer` 항목에서 호스트 옵저버를 활성화하고, `"consumer"`는 전용 `opencode-ext-connector/xai` 컨슈머를 활성화합니다. `xaiOAuth`를 생략하면 통합은 조용히 비활성화됩니다. `xaiOAuth`가 존재하지만 `mode`가 없거나 `"authority" | "consumer"` 중 하나가 아니면 파싱 시점에 거부됩니다. 모든 Claude 자격 증명 옵션과 독립적입니다. |
|
|
81
82
|
| `credentialAuthority.claudeCli.enabled` | `false` | Claude 전용 옵트인: [옵트인 Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용) 참조 |
|
|
82
83
|
| `credentialAuthority.claudeCli.leadMs` | `300000` | Claude 전용 옵트인: 자격 증명 만료 전 이 밀리초만큼 남았을 때 타이머가 `claude`를 호출; 음수가 아닌 정수 |
|
|
83
84
|
| `credentialAuthority.claudeCli.retryMs` | `300000` | Claude 전용 옵트인: 0이 아닌 종료, 잠금 충돌, 시그널, supervisor 실패 후 재시도까지의 밀리초; 양의 정수 |
|
|
@@ -108,7 +109,7 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
|
|
|
108
109
|
|
|
109
110
|
`"owner"`는 내부적으로 external 자격 증명 관리를 선택하고 기본 타이밍으로 Claude CLI 권한을 활성화합니다. `"reader"`는 CLI 권한을 시작하지 않고 external 자격 증명 관리를 선택합니다. 이 역할은 자격 증명 소유권을 뜻하며 OpenCode가 물리 호스트에서 실행되는지, 샌드박스 게스트에서 실행되는지를 뜻하지 않습니다.
|
|
110
111
|
|
|
111
|
-
공유 Claude 로그인마다 owner를 정확히 하나만 설정하십시오. Owner 모드에는 Linux, util-linux `flock`, `PATH`의 Claude Code `2.1.
|
|
112
|
+
공유 Claude 로그인마다 owner를 정확히 하나만 설정하십시오. Owner 모드에는 Linux, util-linux `flock`, `PATH`의 Claude Code `2.1.265` 이상, 인증된 Claude Code 세션이 필요합니다. 각 권한 호출은 실제 모델 요청이며 계정 사용량을 소비할 수 있습니다. 폐기된 로그인은 여전히 대화형 `/login`이 필요합니다.
|
|
112
113
|
|
|
113
114
|
`credentialRole`을 `credentialManagement`, `credentialAuthority`, `credentialRefresh`, `writeBackCredentials`와 함께 사용하지 마십시오. 기존 저수준 설정은 고급 제어용으로 계속 지원됩니다.
|
|
114
115
|
|
|
@@ -203,7 +204,7 @@ Anthropic은 갱신할 때마다 refresh 토큰을 회전시키고 이전 토큰
|
|
|
203
204
|
}
|
|
204
205
|
```
|
|
205
206
|
|
|
206
|
-
설정을 저장한 뒤 OpenCode를 완전히 재시작하십시오. 타이머는 Linux, `PATH`의 util-linux `flock`, Claude Code `2.1.
|
|
207
|
+
설정을 저장한 뒤 OpenCode를 완전히 재시작하십시오. 타이머는 Linux, `PATH`의 util-linux `flock`, Claude Code `2.1.265` 이상, 인증된 Claude Code 세션, writable persistent 상태 디렉터리가 필요합니다. 다른 플랫폼에서는 조용히 비활성화됩니다.
|
|
207
208
|
|
|
208
209
|
`leadMs`와 `retryMs`의 기본값은 모두 `300000`(5분)입니다. 커넥터는 lead 경계에서 restricted single-turn Claude 요청 하나를 예약합니다. 프로세스 공유 non-blocking lock이 같은 상태 디렉터리를 사용하는 프로세스의 동시 요청을 막고, 실패하면 Claude 프로바이더를 제거하지 않은 채 `retryMs` 후 재시도합니다.
|
|
209
210
|
|
|
@@ -211,6 +212,58 @@ Anthropic은 갱신할 때마다 refresh 토큰을 회전시키고 이전 토큰
|
|
|
211
212
|
|
|
212
213
|
타이머가 실행되지 않으면 `flock --version`, `claude --version`, 상태 디렉터리 권한, 두 필수 설정값을 확인하십시오. 반복 경고는 CLI 실행 실패, 시그널 종료, 또는 시작 실패를 뜻합니다. 비활성화하려면 `credentialAuthority`를 제거하거나 `enabled`를 `false`로 설정한 뒤 OpenCode를 완전히 재시작하십시오.
|
|
213
214
|
|
|
215
|
+
### 옵트인 xAI OAuth 권한과 컨슈머 (호스트와 게스트)
|
|
216
|
+
|
|
217
|
+
이 커넥터는 옵트인 xAI 경로를 호스트 측 **authority**와 게스트 측 **consumer** 쌍으로 제공합니다. 두 역할은 `credentialRole`, `credentialManagement`, `credentialAuthority`, `credentialRefresh`, `writeBackCredentials`와 독립적이며 어떤 Claude 정책도 변경하거나 대체하지 않습니다. `xaiOAuth`를 생략하면 통합은 조용히 비활성화됩니다. `xaiOAuth`가 존재하지만 `mode`가 없거나 `"authority" | "consumer"` 중 하나가 아니면 파싱 시점에 거부됩니다.
|
|
218
|
+
|
|
219
|
+
두 역할은 서로 다른 OpenCode 환경에 설정하십시오. 호스트에서는:
|
|
220
|
+
|
|
221
|
+
```jsonc
|
|
222
|
+
{
|
|
223
|
+
"$schema": "https://opencode.ai/config.json",
|
|
224
|
+
"plugin": [["opencode-ext-connector", { "xaiOAuth": { "mode": "authority" } }]]
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
게스트에서는:
|
|
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 (호스트).** 호스트 `connectorServer` 항목에 `xaiOAuth.mode: "authority"`를 설정하십시오. 옵저버는 `XDG_DATA_HOME`이 설정되어 절대 경로일 때 `${XDG_DATA_HOME}/opencode/auth.json`을 읽고, Linux에서 `XDG_DATA_HOME`이 미설정 또는 빈 문자열일 때만 `${HOME}/.local/share/opencode/auth.json`으로 fallback합니다. 상대 경로의 `XDG_DATA_HOME`(설정되었지만 절대 경로가 아님)은 권한을 비활성화합니다 — `auth.json`을 읽지 않고 헬퍼도 호출하지 않습니다. 활성화된 경우 옵저버는 **오직** `xai` 레코드만 검사합니다. 그 레코드의 직렬화 fingerprint가 바뀌면 `${HOME}/.local/bin/opensandbox-xai-auth-sync` 고정 헬퍼를 인자 없이, `PATH`는 `/usr/local/bin:/usr/bin:/bin`, `HOME`은 항상 설정하고 `XDG_DATA_HOME`은 절대 경로일 때만 전달하여 호출합니다. 헬퍼는 OpenSandbox manager에 projection을 위임합니다. 커넥터는 게스트 접근 파일을 기록하거나 xAI OAuth를 발급·갱신하지 않고, 머신 간에 토큰을 전송하거나 refresh 토큰을 게스트에 두지 않습니다. 0이 아닌 종료는 일시적 사례로 처리되어 xAI 프로바이더를 제거하지 않은 채 `5000` ms 후 재시도됩니다.
|
|
238
|
+
|
|
239
|
+
**Consumer (게스트).** 별도의 OpenCode 플러그인 항목으로 `["opencode-ext-connector/xai", { "xaiOAuth": { "mode": "consumer" } }]` 튜플을 로드하십시오. mode는 필수입니다. subpath 항목에 mode가 없으면 빈 hook을 반환하고 xAI 프로바이더는 연결되지 않은 상태로 유지됩니다. `"consumer"` 모드에서 커넥터는 xAI auth hook을 반환하며 `/connect` 메서드를 노출하지 않습니다 — `methods: []`가 공개 계약의 일부입니다. 컨슈머는 `xai` 프로바이더의 OpenCode auth 레코드에 다음 정확한 마커를 요구합니다:
|
|
240
|
+
|
|
241
|
+
```jsonc
|
|
242
|
+
{ "type": "api", "key": "cli-session:xai" }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
커넥터는 이 마커를 기록하지 않으며 마커 배치는 운영자의 책임입니다. 다른 형태면 loader는 auth를 반환하지 않습니다. 센티널로 loader는 자리 표시자 `apiKey`와 래핑된 `fetch`를 반환합니다. 실제 bearer는 모든 외부 요청마다 접근 파일에서 가져옵니다.
|
|
246
|
+
|
|
247
|
+
**접근 파일.** 컨슈머는 **모든** 요청마다 접근 파일을 다시 읽으며 메모리 내 토큰을 신뢰하지 않습니다:
|
|
248
|
+
|
|
249
|
+
- 경로: `XDG_DATA_HOME`이 설정되어 절대 경로일 때 `${XDG_DATA_HOME}/opencode/xai-access.json`, Linux에서 `XDG_DATA_HOME`이 미설정 또는 빈 문자열일 때만 `${HOME}/.local/share/opencode/xai-access.json`. 상대 경로의 `XDG_DATA_HOME`은 HOME fallback을 검사하지 않고 fail-closed합니다.
|
|
250
|
+
- 파일 게이트: 정규 파일, mode `0600`, link 수 `1`, 현재 사용자 소유. 파일 부재, 부적합한 stat, 심볼릭 링크, 다중 링크 파일, 잘못된 소유자, 잘못된 mode는 모두 unavailable 상태를 반환합니다.
|
|
251
|
+
- 스키마 (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
|
+
`"state": "unavailable"` 레코드, malformed JSON, 스키마 불일치, 파일 부재, stat 실패, 또는 `expires`가 현재 시각 이후가 아닌 경우 모두 **모든** 네트워크 호출 **이전**에 실패하며 해당 요청에 대해 `XaiAccessUnavailableError`를 던집니다. bearer 토큰은 플러그인 인수, 커넥터 stdout/stderr 진단, 또는 요청 URL에 들어가지 않습니다.
|
|
264
|
+
|
|
265
|
+
**범위 밖.** OpenCode auth에 직접 저장된 일반 xAI API 키(내장 `XAI_API_KEY` 방식 경로)는 이 권한/컨슈머 쌍이 아니라 OpenCode의 내장 `xai` 프로바이더를 따릅니다. 커넥터는 그러한 키를 가져오거나, 미러링하거나, 프록시하지 않습니다. 헬퍼는 idempotent해야 합니다. 같은 게스트에서 개발자 Claude Code 세션을 실행해도 projected 접근 파일에 대한 권한을 갖지 않습니다.
|
|
266
|
+
|
|
214
267
|
## 호스트/게스트 샌드박스 설정
|
|
215
268
|
|
|
216
269
|
OpenCode가 컨테이너, VM 또는 다른 샌드박스에서 실행될 때 그 런타임을 **게스트**, 벤더 로그인과 Ollama 데몬을 소유한 머신을 **호스트**로 봅니다. 게스트에는 자체 `localhost`, 홈 디렉터리, 환경, keychain, 파일 권한, 네트워크 namespace가 있습니다. 파일을 mount하거나 환경 값을 명시적으로 주입하지 않으면 호스트 세션은 게스트에 보이지 않습니다.
|
|
@@ -223,6 +276,7 @@ OpenCode가 컨테이너, VM 또는 다른 샌드박스에서 실행될 때 그
|
|
|
223
276
|
| Cursor | Cursor CLI 로그인을 소유 | 자격 증명 파일을 게스트의 `${HOME}/.config/cursor/auth.json`에 read-only로 mount하거나 샌드박스 secret 기능으로 `CURSOR_ACCESS_TOKEN` 주입; 게스트에 Node.js 22 이상 설치 |
|
|
224
277
|
| Command Code | CLI 로그인 또는 API 키를 소유 | `${HOME}/.commandcode/auth.json`을 read-only로 mount하거나 `COMMAND_CODE_API_KEY` 주입; `COMMAND_CODE_CLI_VERSION`, 설치된 `command-code` 바이너리 또는 npm registry 접근으로 클라이언트 버전 확인 |
|
|
225
278
|
| Ollama | 신뢰하는 데몬을 실행하고 Cloud 접근이 필요하면 그곳에서 `ollama signin` 실행 | Ollama 자격 증명을 복사하지 않고 `ollamaBaseURL`로 선택한 데몬에만 연결 |
|
|
279
|
+
| xAI | 호스트 권한이 `~/.local/bin/opensandbox-xai-auth-sync`을 호출하고 OpenSandbox manager가 access-only 게스트 상태를 projection | manager가 projection한 `{"type":"api","key":"cli-session:xai"}` 마커와 `${XDG_DATA_HOME}/opencode/xai-access.json`(또는 XDG가 미설정 또는 빈 문자열일 때 HOME fallback)을 사용하고, `xaiOAuth.mode: "consumer"`로 `opencode-ext-connector/xai`를 로드; refresh 토큰은 절대 복사하거나 mount하지 않음 |
|
|
226
280
|
|
|
227
281
|
예를 들어 Linux 게스트는 다음 경로와 선택적인 secret/version override를 사용할 수 있습니다. Mount source와 destination은 샌드박스 런타임에 맞게 조정하십시오:
|
|
228
282
|
|
|
@@ -311,11 +365,14 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
|
|
|
311
365
|
| **Cursor** | CLI 액세스 토큰으로 Cursor의 미공개 클라이언트 프로토콜(`api2.cursor.sh` `AgentService`, HTTP/2 위의 Connect+protobuf)을 호출합니다. 두 자격 증명 관리 모드 모두 자격 증명은 읽기 전용입니다. direct 생성은 정확한 HTTP 401이 출력이나 효과 전에 발생할 때만 null이 아니며 변경된 자격 증명을 다시 읽고 한 번 재시도할 수 있으며, 이는 갱신이나 writeback이 아닙니다. legacy/compatibility 생성은 한 번만 시도합니다. 플러그인이 소유한 Node 자식 프로세스가 private stdio로 통신하고, 툴 결과를 같은 bidi Run에 유지하며, parked call을 절대 재실행하지 않고, 사용자 대면 데몬을 열지 않으며, 생성에 `cursor-agent`를 절대 spawn하지 않습니다. 비공식이며 공개 Cursor API가 아닙니다. 프로토콜이 어긋난 뒤에는 암시적 fallback이 없습니다 — 해당 프로바이더가 실패합니다. Node.js 22 이상이 필요합니다. 라이브 카탈로그 id가 있으면 그것을 쓰고, 없으면 문서화된 fallback은 `default`입니다. |
|
|
312
366
|
| **Command Code** | CLI 호환 요청 메타데이터와 함께 `/alpha/generate`를 호출하고, 프로바이더 로컬 NDJSON 텍스트와 툴 이벤트를 스트림합니다. 두 자격 증명 관리 모드 모두 자격 증명은 읽기 전용입니다. 정확한 HTTP 401이 출력이나 효과 전에 발생하면 null이 아니며 변경된 자격 증명을 다시 읽고 한 번만 재시도할 수 있으며, 이는 갱신이나 writeback이 아닙니다. 클라이언트 버전은 `COMMAND_CODE_CLI_VERSION`, 설치된 `command-code` 바이너리, 또는 npm registry에서 가져옵니다. 요청 메타데이터에는 Node.js 버전, 플랫폼, 아키텍처, 절대 작업 디렉터리가 포함됩니다. 라이브 카탈로그 id가 있으면 그것을 쓰고, 없으면 문서화된 fallback은 `Qwen/Qwen3.8-Max`입니다. |
|
|
313
367
|
| **Ollama** | `credentialManagement`의 영향을 받지 않습니다. `ollamaBaseURL`로 선택한 신뢰된 데몬(기본값 `http://localhost:11434`)의 `/api/tags`, `/api/pull`, `/api/chat`을 사용하며 경로 prefix를 보존합니다. 이미 pull된 모델과, 커넥터 자격 증명 없이 Ollama 공식 Cloud 검색 및 library 페이지에서 익명으로 발견한 정확한 Cloud 태그를 공개합니다. 정확히 중복되는 항목은 로컬이 이깁니다. 불완전한 Cloud 갱신은 마지막 완전한 목록을 유지합니다. 없는 인가된 Cloud 태그를 선택하면 최초 사용 시 lightweight remote reference를 pull합니다. 같은 정규화 base와 태그의 동시 pull은 하나의 in-flight 요청을 공유하며 실패한 pull은 재시도할 수 있습니다. 데몬은 사용자의 Ollama Cloud 구독으로 Cloud 태그 프롬프트를 proxy할 수 있습니다. 커넥터는 Ollama API 키, 사용량 과금 direct Cloud API, `OLLAMA_HOST`, 자격 증명/custom header, cookie, direct Cloud 생성 endpoint를 사용하지 않습니다. |
|
|
368
|
+
| **xAI** | 패키지 subpath `opencode-ext-connector/xai`로만 로드되는 옵트인 OAuth 컨슈머입니다. 접근 파일은 모든 요청마다 다시 읽고, `/connect` 메서드를 노출하지 않으며, xAI OAuth를 발급하거나 갱신하지 않습니다. Claude `credentialRole`과 자격 증명 정책에서 독립적입니다. [옵트인 xAI OAuth 권한과 컨슈머](#옵트인-xai-oauth-권한과-컨슈머-호스트와-게스트) 참조. |
|
|
314
369
|
|
|
315
370
|
프로바이더 health는 격리됩니다. 한 프로바이더가 실패해도 나머지는 제거되지 않습니다.
|
|
316
371
|
|
|
317
372
|
독립 SDK entry는 `opencode-ext-connector/ollama`입니다. `{ ollamaBaseURL }`을 전달해 같은 신뢰된 데몬을 선택할 수 있습니다. 해당 데몬에 이미 있는 모델로 생성할 수 있으며, 커넥터가 관리하는 Cloud 자동 pull은 그 정규화 base의 활성 Ollama 카탈로그 lease가 필요합니다.
|
|
318
373
|
|
|
374
|
+
전용 컨슈머 entry는 `opencode-ext-connector/xai`입니다. `{ xaiOAuth: { mode: "consumer" } }`을 전달해 모델 프로바이더, 모델, `/connect` 메서드를 등록하지 않고 xAI auth hook만 등록합니다.
|
|
375
|
+
|
|
319
376
|
## 문제 해결
|
|
320
377
|
|
|
321
378
|
| 증상 | 확인할 것 |
|
|
@@ -329,6 +386,8 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
|
|
|
329
386
|
| Cursor 생성이 실패함 | Node.js 22 이상이 필요합니다. 생성은 `cursor-agent`가 아니라 private Node 자식 프로세스를 통한 미공개 프로토콜을 사용합니다. 프로토콜이 어긋나면 해당 프로바이더가 실패하며, 암시적 fallback은 없습니다. |
|
|
330
387
|
| Command Code 생성이 실패함 | 클라이언트 버전을 확인할 수 없었습니다: `COMMAND_CODE_CLI_VERSION`을 설정하거나, `command-code`를 설치하거나, `registry.npmjs.org` 접근을 허용하십시오. 요청 메타데이터에는 Node.js 버전, 플랫폼, 아키텍처, 절대 작업 디렉터리가 포함됩니다. |
|
|
331
388
|
| `opencode models ollama`에 Ollama가 없음 | `ollamaBaseURL`(또는 기본 `localhost:11434`)에서 신뢰하는 데몬을 시작한 뒤 `/connect`하십시오. 경로 prefix가 Ollama `/api/*` route에 도달하는지 확인하십시오. Cloud 태그는 익명 catalog 항목이며 `OLLAMA_HOST`, API 키, credential header, redirect, direct Cloud 생성은 사용하지 않습니다. |
|
|
389
|
+
| xAI 요청이 `XaiAccessUnavailableError`로 실패함 | 호스트 `connectorServer` 항목의 `xaiOAuth.mode`가 `"authority"`인지, `opencode-ext-connector/xai` subpath 항목의 `xaiOAuth.mode`가 `"consumer"`인지, OpenCode auth 레코드가 정확히 `{"type":"api","key":"cli-session:xai"}`를 담고 있는지, 접근 파일이 `${XDG_DATA_HOME}/opencode/xai-access.json`(또는 Linux에서 `XDG_DATA_HOME`이 미설정 또는 빈 문자열일 때 `${HOME}/.local/share/opencode/xai-access.json`)에 현재 사용자 소유의 정규 단일 링크 mode `0600` 파일로 존재하고 유효한 closed v1 스키마와 미래의 `expires`를 가지는지 확인하십시오. 상대 경로의 `XDG_DATA_HOME`은 HOME fallback 없이 fail-closed합니다. 오류는 모든 네트워크 호출 전에 발생합니다. |
|
|
390
|
+
| 호스트 xAI 권한 헬퍼가 실행되지 않음 | `${HOME}/.local/bin/opensandbox-xai-auth-sync`이 존재하고 실행 가능하며 성공 시 `0`으로 종료하는지 확인하십시오. 옵저버는 모든 0이 아닌 종료를 일시적 사례로 처리해 `5000` ms 후 재시도하며 xAI 프로바이더를 제거하지 않습니다. 헬퍼는 OpenSandbox manager에 projection을 위임합니다. 상대 경로의 `XDG_DATA_HOME`(설정되었지만 절대 경로가 아님)은 읽기와 호출 없이 권한을 비활성화합니다. |
|
|
332
391
|
| 호스트 자격 증명이 있지만 게스트 프로바이더가 연결되지 않음 | mount 대상과 권한, 게스트의 `HOME`, `CLAUDE_CONFIG_DIR`, 주입한 secret 환경, writable 게스트 OpenCode `auth.json`, 게스트 안에서 `/connect`가 완료됐는지 확인하십시오. |
|
|
333
392
|
| Ollama가 호스트에서는 동작하지만 게스트에서는 동작하지 않음 | 게스트 `localhost`는 보통 호스트가 아닙니다. `host.docker.internal` resolve, Linux `host-gateway` mapping, 데몬 bind 주소, firewall과 샌드박스 egress, base path prefix가 Ollama `/api/*` route에 도달하는지 확인하십시오. |
|
|
334
393
|
| 한 프로바이더가 다운됨 | 실패는 격리됩니다. 일시적 스냅샷 실패는 마지막으로 알려진 카탈로그를 유지하고, unavailable 스냅샷은 해당 커넥터 소유 프로바이더만 제거합니다. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ext-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "External provider connector for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./dist/index.d.ts",
|
|
21
21
|
"import": "./dist/index.js"
|
|
22
22
|
},
|
|
23
|
+
"./xai": {
|
|
24
|
+
"types": "./dist/xai.d.ts",
|
|
25
|
+
"import": "./dist/xai.js"
|
|
26
|
+
},
|
|
23
27
|
"./command-code": {
|
|
24
28
|
"types": "./dist/sdk/command-code.d.ts",
|
|
25
29
|
"import": "./dist/sdk/command-code.js"
|