opencode-ext-connector 0.4.0 → 0.6.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-09-16
4
+
5
+ - Add the recommended `credentialRole: "owner" | "reader"` option for shared
6
+ Claude logins: one Linux owner runs the existing Claude CLI authority while
7
+ readers consume externally managed credentials without refreshing them
8
+ - Keep the existing low-level credential policy options available for advanced
9
+ control, but reject combining any of them with `credentialRole`
10
+ - Keep omitted credential policy behavior and non-Claude providers unchanged
11
+
12
+ ## 0.5.0 - 2026-09-15
13
+
14
+ - Add the disabled-by-default Claude CLI credential authority for Linux
15
+ deployments using `credentialManagement: "external"`
16
+ - Schedule one restricted Claude Code request before credential expiry, with
17
+ process-shared `flock` coordination, retry handling, and clean shutdown
18
+ - Require util-linux `flock` and Claude Code `2.1.259` or later when enabled;
19
+ each invocation is a real model request and may consume account usage
20
+ - Keep OAuth, credential persistence, and other providers unchanged; revoked
21
+ Claude sessions still require interactive `/login`
22
+
3
23
  ## 0.4.0 - 2026-09-11
4
24
 
5
25
  - Add explicit `ollamaBaseURL` support for trusted remote and self-hosted Ollama
package/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  ## Status
21
21
 
22
- > Independent unofficial community plugin, version **0.4.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.6.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,7 +55,7 @@ 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.4.0"]
58
+ "plugin": ["opencode-ext-connector@0.6.0"]
59
59
  }
60
60
  ```
61
61
 
@@ -73,15 +73,47 @@ 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
+ | `credentialAuthority.claudeCli.enabled` | `false` | Claude-only opt-in: see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only) |
82
+ | `credentialAuthority.claudeCli.leadMs` | `300000` | Claude-only opt-in: milliseconds before credential expiry at which the timer invokes `claude`; non-negative integer |
83
+ | `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 |
80
84
  | `catalogReloadMs` | `300000` | Re-run catalog snapshots on this interval; `0` disables |
81
85
  | `snapshotTimeoutMs` | `30000` | Per-provider snapshot deadline |
82
86
  | `health.initialBackoffMs` | `1000` | Health backoff after a failed snapshot |
83
87
  | `health.maximumBackoffMs` | `60000` | Health backoff cap |
84
88
 
89
+ ### Credential ownership (recommended)
90
+
91
+ Choose `"owner"` on the one Linux instance that owns and refreshes the shared Claude Code login:
92
+
93
+ ```jsonc
94
+ {
95
+ "$schema": "https://opencode.ai/config.json",
96
+ "plugin": [["opencode-ext-connector", { "credentialRole": "owner" }]]
97
+ }
98
+ ```
99
+
100
+ Choose `"reader"` on every instance that only consumes synchronized credentials:
101
+
102
+ ```jsonc
103
+ {
104
+ "$schema": "https://opencode.ai/config.json",
105
+ "plugin": [["opencode-ext-connector", { "credentialRole": "reader" }]]
106
+ }
107
+ ```
108
+
109
+ `"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
+ 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`.
112
+
113
+ Do not combine `credentialRole` with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials`. Existing low-level configurations remain supported for advanced control.
114
+
115
+ ### Advanced credential policy
116
+
85
117
  `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:
86
118
 
87
119
  ```jsonc
@@ -114,7 +146,7 @@ Use external authority when another process manages credentials:
114
146
  }
115
147
  ```
116
148
 
117
- `credentialManagement: "external"` prohibits connector refresh and writeback. Today Claude maps to never-refresh/no-write and can re-read externally managed credentials after a 401. When all credential-policy options are omitted, Claude preserves the legacy defaults: automatic refresh with a `60_000` ms lead and no writeback. If only `credentialManagement` is omitted, supplied deprecated `credentialRefresh` or `writeBackCredentials` options still control behavior. Cursor direct generation and Command Code remain read-only under both modes: on an exact pre-output HTTP 401, they re-read a changed, non-null credential and retry once only. This safe reread is not refresh or writeback, so both modes allow it; Cursor legacy/compatibility generation remains one-shot. Ollama is unaffected. This option does not log in, mint OAuth, synchronize machines, or imply that credentials use file storage.
149
+ `credentialManagement: "external"` prohibits connector refresh and writeback. Today Claude maps to never-refresh/no-write and can re-read externally managed credentials after a 401. To keep that externally managed credential file current from a single Linux host, you can additionally enable the Claude-only [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only); that timer is independent of the connector's own refresh path and off by default. When all credential-policy options are omitted, Claude preserves the legacy defaults: automatic refresh with a `60_000` ms lead and no writeback. If only `credentialManagement` is omitted, supplied deprecated `credentialRefresh` or `writeBackCredentials` options still control behavior. Cursor direct generation and Command Code remain read-only under both modes: on an exact pre-output HTTP 401, they re-read a changed, non-null credential and retry once only. This safe reread is not refresh or writeback, so both modes allow it; Cursor legacy/compatibility generation remains one-shot. Ollama is unaffected. This option does not log in, mint OAuth, synchronize machines, or imply that credentials use file storage.
118
150
 
119
151
  For one migration cycle, `writeBackCredentials` and `credentialRefresh.*` remain accepted when used without `credentialManagement`; custom `credentialRefresh.leadMs` values still require the legacy configuration. Mixing the new option with either legacy option is rejected with: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``.
120
152
 
@@ -140,12 +172,45 @@ With all credential-policy options omitted, refreshed Claude tokens stay in memo
140
172
 
141
173
  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:
142
174
 
143
- - **Refresh authority** (where you log in): `credentialManagement: "connector"`. If you need a custom publication window, use the deprecated legacy options alone for this migration cycle, for example `writeBackCredentials: true` with `credentialRefresh: { mode: "auto", leadMs: 1800000 }`.
144
- - **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.
175
+ - **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).
176
+ - **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.
145
177
  - 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.
146
178
 
147
179
  Machines that refresh on their own — including a Claude Code install that is used interactively — must not share the file. Log in separately there.
148
180
 
181
+ ### Opt-in Claude CLI authority timer (Claude-only)
182
+
183
+ 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"`.
184
+
185
+ Low-level equivalent:
186
+
187
+ ```jsonc
188
+ {
189
+ "$schema": "https://opencode.ai/config.json",
190
+ "plugin": [
191
+ [
192
+ "opencode-ext-connector",
193
+ {
194
+ "credentialManagement": "external",
195
+ "credentialAuthority": {
196
+ "claudeCli": {
197
+ "enabled": true
198
+ }
199
+ }
200
+ }
201
+ ]
202
+ ]
203
+ }
204
+ ```
205
+
206
+ After saving the configuration, fully restart OpenCode. The timer requires Linux, util-linux `flock`, Claude Code `2.1.259` or later on `PATH`, an authenticated Claude Code session, and a writable persistent state directory. It is silently disabled on other platforms.
207
+
208
+ `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
+ Each invocation is a real model request and can count against the account's subscription or billing. Run the timer on exactly one machine per login. It cannot revive a revoked or logged-out session; run interactive `/login` again in that case. Review Anthropic's current [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) before enabling it.
211
+
212
+ 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
+
149
214
  ## Host/Guest Sandbox Setup
150
215
 
151
216
  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.
@@ -154,7 +219,7 @@ The safest shared-session layout keeps each vendor login owned and refreshed by
154
219
 
155
220
  | Provider | Host | Guest |
156
221
  | --- | --- | --- |
157
- | 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 |
222
+ | 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 |
158
223
  | 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 |
159
224
  | 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 |
160
225
  | 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` |
@@ -184,7 +249,7 @@ Use this complete guest `opencode.json` when all four providers are enabled and
184
249
  {
185
250
  "providers": ["claude", "cursor", "command-code", "ollama"],
186
251
  "ollamaBaseURL": "http://host.docker.internal:11434",
187
- "credentialManagement": "external",
252
+ "credentialRole": "reader",
188
253
  "catalogReloadMs": 300000,
189
254
  "snapshotTimeoutMs": 30000,
190
255
  "health": {
@@ -197,7 +262,7 @@ Use this complete guest `opencode.json` when all four providers are enabled and
197
262
  }
198
263
  ```
199
264
 
200
- `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.
265
+ `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.
201
266
 
202
267
  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:
203
268
 
@@ -208,7 +273,7 @@ extra_hosts:
208
273
 
209
274
  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.
210
275
 
211
- 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.
276
+ 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.
212
277
 
213
278
  ## Update and Remove
214
279
 
@@ -242,7 +307,7 @@ Ollama `/connect` probes the configured daemon and stores the exact session mark
242
307
 
243
308
  | Provider | What it does |
244
309
  | --- | --- |
245
- | **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. Omitting all credential-policy options preserves legacy auto/`60_000` behavior without writeback; if only `credentialManagement` is omitted, supplied deprecated options still control behavior. |
310
+ | **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. |
246
311
  | **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`. |
247
312
  | **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`. |
248
313
  | **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. |
@@ -258,8 +323,8 @@ The standalone SDK entry is `opencode-ext-connector/ollama`; pass `{ ollamaBaseU
258
323
  | `/connect` methods missing | Confirm `plugin` contains `"opencode-ext-connector"` or an exact published `"opencode-ext-connector@<version>"` spec, then fully restart OpenCode. |
259
324
  | 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. |
260
325
  | 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. |
261
- | Claude reports `invalid_grant` on shared credentials | Another machine with the same login already refreshed and rotated the refresh token. Give one machine refresh authority with `credentialManagement: "connector"` and use `"external"` on the others, or log in separately. |
262
- | 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. |
326
+ | 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. |
327
+ | 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. |
263
328
  | `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. |
264
329
  | 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. |
265
330
  | 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. |
@@ -2,14 +2,31 @@ 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";
5
6
  export type CredentialRefreshPolicy = {
6
7
  readonly mode: CredentialRefreshMode;
7
8
  readonly leadMs: number;
8
9
  };
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
+ };
9
18
  export type ConnectorOptionsInput = {
10
19
  readonly providers?: readonly ("claude" | "cursor" | "command-code" | "ollama")[] | undefined;
11
20
  readonly snapshotTimeoutMs?: number | undefined;
21
+ readonly credentialRole?: CredentialRole | undefined;
12
22
  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;
13
30
  /** @deprecated Use credentialManagement instead. */
14
31
  readonly writeBackCredentials?: boolean | undefined;
15
32
  /** @deprecated Use credentialManagement instead. */
@@ -28,6 +45,7 @@ export type ConnectorOptions = {
28
45
  readonly snapshotTimeoutMs: number;
29
46
  readonly writeBackCredentials: boolean;
30
47
  readonly credentialRefresh: CredentialRefreshPolicy;
48
+ readonly credentialAuthority: CredentialAuthority;
31
49
  readonly catalogReloadMs: number;
32
50
  readonly health: HealthPolicy;
33
51
  };
@@ -5,14 +5,30 @@ 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 SafeIntegerSchema = z.number().int().safe();
8
10
  const DefaultProviders = [
9
11
  "claude",
10
12
  "cursor",
11
13
  "command-code",
12
14
  "ollama",
13
15
  ];
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();
14
29
  function resolveCredentialOptions(input) {
15
- switch (input.credentialManagement) {
30
+ const credentialManagement = input.credentialRole === undefined ? input.credentialManagement : "external";
31
+ switch (credentialManagement) {
16
32
  case undefined:
17
33
  return {
18
34
  credentialRefresh: Object.freeze({
@@ -37,7 +53,9 @@ const ConnectorOptionsInputSchema = z
37
53
  .object({
38
54
  providers: z.array(ProviderSchema).optional(),
39
55
  snapshotTimeoutMs: PositiveSafeIntegerSchema.optional(),
56
+ credentialRole: CredentialRoleSchema.optional(),
40
57
  credentialManagement: CredentialManagementSchema.optional(),
58
+ credentialAuthority: CredentialAuthorityInputSchema.optional(),
41
59
  writeBackCredentials: z.boolean().optional(),
42
60
  credentialRefresh: z
43
61
  .object({
@@ -57,6 +75,17 @@ const ConnectorOptionsInputSchema = z
57
75
  })
58
76
  .strict()
59
77
  .superRefine((input, context) => {
78
+ if (input.credentialRole !== undefined &&
79
+ (input.credentialManagement !== undefined ||
80
+ input.credentialAuthority !== undefined ||
81
+ input.credentialRefresh !== undefined ||
82
+ input.writeBackCredentials !== undefined)) {
83
+ context.addIssue({
84
+ code: "custom",
85
+ path: ["credentialRole"],
86
+ message: "`credentialRole` cannot be combined with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials`",
87
+ });
88
+ }
60
89
  if (input.credentialManagement !== undefined &&
61
90
  (input.credentialRefresh !== undefined || input.writeBackCredentials !== undefined)) {
62
91
  context.addIssue({
@@ -65,6 +94,23 @@ const ConnectorOptionsInputSchema = z
65
94
  message: "`credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials`",
66
95
  });
67
96
  }
97
+ if (input.credentialAuthority?.claudeCli.enabled === true &&
98
+ (input.credentialManagement !== "external" ||
99
+ !(input.providers ?? DefaultProviders).includes("claude"))) {
100
+ context.addIssue({
101
+ code: "custom",
102
+ path: ["credentialAuthority", "claudeCli", "enabled"],
103
+ message: "Claude CLI credential authority requires external credential management and the Claude provider",
104
+ });
105
+ }
106
+ if (input.credentialRole === "owner" &&
107
+ !(input.providers ?? DefaultProviders).includes("claude")) {
108
+ context.addIssue({
109
+ code: "custom",
110
+ path: ["credentialRole"],
111
+ message: "Credential owner role requires the Claude provider",
112
+ });
113
+ }
68
114
  const initialBackoffMs = input.health?.initialBackoffMs ?? 1_000;
69
115
  const maximumBackoffMs = input.health?.maximumBackoffMs ?? 60_000;
70
116
  if (initialBackoffMs > maximumBackoffMs) {
@@ -77,11 +123,21 @@ export const ConnectorOptionsSchema = ConnectorOptionsInputSchema.transform((inp
77
123
  maximumBackoffMs: input.health?.maximumBackoffMs ?? 60_000,
78
124
  });
79
125
  const credentialOptions = resolveCredentialOptions(input);
126
+ const credentialAuthority = Object.freeze({
127
+ claudeCli: Object.freeze({
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
+ });
80
135
  return Object.freeze({
81
136
  providers: Object.freeze(input.providers ?? DefaultProviders),
82
137
  snapshotTimeoutMs: input.snapshotTimeoutMs ?? 30_000,
83
138
  writeBackCredentials: credentialOptions.writeBackCredentials,
84
139
  credentialRefresh: credentialOptions.credentialRefresh,
140
+ credentialAuthority,
85
141
  catalogReloadMs: input.catalogReloadMs ?? 300_000,
86
142
  health,
87
143
  });
@@ -1,7 +1,9 @@
1
1
  import type { ConnectorOptionsInput } from "../core/options.js";
2
- type HostConnectorOptionsInput = Omit<ConnectorOptionsInput, "credentialManagement" | "credentialRefresh" | "writeBackCredentials"> & {
2
+ type HostConnectorOptionsInput = Omit<ConnectorOptionsInput, "credentialAuthority" | "credentialManagement" | "credentialRefresh" | "credentialRole" | "writeBackCredentials"> & {
3
+ readonly credentialAuthority?: unknown;
3
4
  readonly credentialManagement?: unknown;
4
5
  readonly credentialRefresh?: unknown;
6
+ readonly credentialRole?: unknown;
5
7
  readonly writeBackCredentials?: unknown;
6
8
  };
7
9
  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,15 @@ 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
+ ...(!("credentialAuthority" in input) || input.credentialAuthority === undefined
63
+ ? {}
64
+ : { credentialAuthority: input.credentialAuthority }),
57
65
  ...(credentialRefresh === undefined ? {} : { credentialRefresh }),
58
66
  catalogReloadMs: "catalogReloadMs" in input ? nonNegativeInteger(input.catalogReloadMs) : undefined,
59
67
  health: "health" in input ? pickHealth(input.health) : undefined,
@@ -0,0 +1,20 @@
1
+ import type { ProcessCommand, ProcessExit, ProcessSupervisor } from "../core/process.js";
2
+ export type ProcessSpawnOptions = {
3
+ readonly shell: false;
4
+ readonly stdio: "ignore";
5
+ readonly windowsHide: true;
6
+ };
7
+ export interface SpawnedChild {
8
+ readonly exitCode: number | null;
9
+ readonly signalCode: string | null;
10
+ onSpawn(listener: () => void): () => void;
11
+ onError(listener: (error: Error) => void): () => void;
12
+ onExit(listener: (exit: ProcessExit) => void): () => void;
13
+ kill(signal: "SIGTERM" | "SIGKILL"): boolean;
14
+ }
15
+ export type ProcessSpawn = (command: ProcessCommand, options: ProcessSpawnOptions) => SpawnedChild;
16
+ export type ProductionProcessSupervisorOptions = {
17
+ readonly spawn?: ProcessSpawn;
18
+ readonly terminationGraceMs?: number;
19
+ };
20
+ export declare function createProductionProcessSupervisor(options?: ProductionProcessSupervisorOptions): ProcessSupervisor;
@@ -0,0 +1,171 @@
1
+ import { spawn } from "node:child_process";
2
+ import { OperationCancelledError, ProcessSupervisorError, ResourceDisposedError, } from "../core/errors.js";
3
+ import { createAsyncDisposable } from "../core/lifecycle.js";
4
+ const spawnOptions = {
5
+ shell: false,
6
+ stdio: "ignore",
7
+ windowsHide: true,
8
+ };
9
+ function spawnNodeChild(command, options) {
10
+ const child = spawn(command.executable, [...command.arguments], {
11
+ cwd: command.cwd ?? undefined,
12
+ shell: options.shell,
13
+ stdio: options.stdio,
14
+ windowsHide: options.windowsHide,
15
+ });
16
+ return {
17
+ get exitCode() {
18
+ return child.exitCode;
19
+ },
20
+ get signalCode() {
21
+ return child.signalCode;
22
+ },
23
+ onSpawn: (listener) => {
24
+ child.once("spawn", listener);
25
+ return () => child.removeListener("spawn", listener);
26
+ },
27
+ onError: (listener) => {
28
+ child.once("error", listener);
29
+ return () => child.removeListener("error", listener);
30
+ },
31
+ onExit: (listener) => {
32
+ const onExit = (code, signal) => {
33
+ listener(code === null ? { kind: "signal", signal: signal ?? "unknown" } : { kind: "code", code });
34
+ };
35
+ child.once("exit", onExit);
36
+ return () => child.removeListener("exit", onExit);
37
+ },
38
+ kill: (signal) => child.kill(signal),
39
+ };
40
+ }
41
+ function processFailure(operation, cause) {
42
+ return new ProcessSupervisorError({ operation, retryable: true, cause });
43
+ }
44
+ class ProductionSupervisedProcess {
45
+ completion;
46
+ spawned;
47
+ disposal;
48
+ constructor(child, terminationGraceMs) {
49
+ this.spawned = new Promise((resolve, reject) => {
50
+ const removeSpawn = child.onSpawn(() => {
51
+ removeError();
52
+ resolve();
53
+ });
54
+ const removeError = child.onError((error) => {
55
+ removeSpawn();
56
+ reject(processFailure("spawn", error));
57
+ });
58
+ });
59
+ this.completion = new Promise((resolve, reject) => {
60
+ const removeExit = child.onExit((exit) => {
61
+ removeError();
62
+ resolve(exit);
63
+ });
64
+ const removeError = child.onError((error) => {
65
+ removeExit();
66
+ reject(processFailure("wait", error));
67
+ });
68
+ });
69
+ this.disposal = createAsyncDisposable(async () => {
70
+ if (child.exitCode !== null || child.signalCode !== null)
71
+ return;
72
+ child.kill("SIGTERM");
73
+ let timer;
74
+ await Promise.race([
75
+ this.completion,
76
+ new Promise((resolve) => {
77
+ timer = setTimeout(resolve, terminationGraceMs);
78
+ timer.unref();
79
+ }),
80
+ ]);
81
+ if (timer !== undefined)
82
+ clearTimeout(timer);
83
+ if (child.exitCode === null && child.signalCode === null) {
84
+ if (!child.kill("SIGKILL"))
85
+ throw processFailure("terminate", null);
86
+ await this.completion;
87
+ }
88
+ });
89
+ }
90
+ ready(signal) {
91
+ return this.raceCancellation(this.spawned, signal, "start-process");
92
+ }
93
+ wait(signal) {
94
+ return this.raceCancellation(this.completion, signal, "wait-process");
95
+ }
96
+ terminate() {
97
+ return this.disposal.dispose();
98
+ }
99
+ dispose() {
100
+ return this.disposal.dispose();
101
+ }
102
+ [Symbol.asyncDispose]() {
103
+ return this.disposal.dispose();
104
+ }
105
+ raceCancellation(operation, signal, name) {
106
+ if (signal.aborted)
107
+ return Promise.reject(new OperationCancelledError(name));
108
+ const deferred = Promise.withResolvers();
109
+ const onAbort = () => deferred.reject(new OperationCancelledError(name));
110
+ signal.addEventListener("abort", onAbort, { once: true });
111
+ operation.then((value) => {
112
+ signal.removeEventListener("abort", onAbort);
113
+ deferred.resolve(value);
114
+ }, (error) => {
115
+ signal.removeEventListener("abort", onAbort);
116
+ deferred.reject(error);
117
+ });
118
+ return deferred.promise;
119
+ }
120
+ }
121
+ export function createProductionProcessSupervisor(options = {}) {
122
+ const spawnProcess = options.spawn ?? spawnNodeChild;
123
+ const terminationGraceMs = options.terminationGraceMs ?? 1_000;
124
+ const active = new Set();
125
+ const disposalController = new AbortController();
126
+ let disposalStarted = false;
127
+ const disposal = createAsyncDisposable(async () => {
128
+ disposalStarted = true;
129
+ disposalController.abort();
130
+ await Promise.all([...active].map((process) => process.terminate()));
131
+ });
132
+ const start = async (command, signal) => {
133
+ if (signal.aborted)
134
+ throw new OperationCancelledError("start-process");
135
+ if (disposalStarted)
136
+ throw new ResourceDisposedError("process-supervisor");
137
+ let child;
138
+ try {
139
+ child = spawnProcess(command, spawnOptions);
140
+ }
141
+ catch (error) {
142
+ throw processFailure("spawn", error);
143
+ }
144
+ const process = new ProductionSupervisedProcess(child, terminationGraceMs);
145
+ active.add(process);
146
+ try {
147
+ await process.ready(AbortSignal.any([signal, disposalController.signal]));
148
+ if (disposalStarted) {
149
+ await process.terminate();
150
+ throw new ResourceDisposedError("process-supervisor");
151
+ }
152
+ return process;
153
+ }
154
+ catch (error) {
155
+ active.delete(process);
156
+ if (error instanceof OperationCancelledError)
157
+ await process.terminate();
158
+ if (disposalStarted)
159
+ throw new ResourceDisposedError("process-supervisor");
160
+ throw error;
161
+ }
162
+ finally {
163
+ void process.wait(new AbortController().signal).then(() => active.delete(process), () => active.delete(process));
164
+ }
165
+ };
166
+ return {
167
+ start,
168
+ dispose: disposal.dispose,
169
+ [Symbol.asyncDispose]: disposal[Symbol.asyncDispose],
170
+ };
171
+ }
@@ -0,0 +1,23 @@
1
+ import type { Clock } from "../../core/clock.js";
2
+ import { type AsyncDisposableHandle } from "../../core/lifecycle.js";
3
+ import type { ConnectorLogger } from "../../core/logger.js";
4
+ import type { ProcessSupervisor } from "../../core/process.js";
5
+ import type { ClaudeCredentials } from "./credentials.js";
6
+ export type ClaudeCredentialReader = (env: Readonly<Record<string, string | undefined>>, signal: AbortSignal) => Promise<ClaudeCredentials | null>;
7
+ export type ClaudeCredentialAuthorityEnvironment = Readonly<Record<string, string | undefined>> & {
8
+ readonly HOME?: string;
9
+ readonly XDG_STATE_HOME?: string;
10
+ };
11
+ export type ClaudeCredentialAuthoritySchedulerOptions = {
12
+ readonly enabled: boolean;
13
+ readonly platform?: string;
14
+ readonly clock: Clock;
15
+ readonly leadMs: number;
16
+ readonly retryMs: number;
17
+ readonly env: ClaudeCredentialAuthorityEnvironment;
18
+ readonly processSupervisor: ProcessSupervisor;
19
+ readonly logger: ConnectorLogger;
20
+ readonly readCredentials?: ClaudeCredentialReader;
21
+ readonly ensureStateDirectory?: (path: string) => Promise<void>;
22
+ };
23
+ export declare function createClaudeCredentialAuthorityScheduler(options: ClaudeCredentialAuthoritySchedulerOptions): AsyncDisposableHandle;
@@ -0,0 +1,181 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { InvalidArgumentError, OperationCancelledError, ProcessSupervisorError, } from "../../core/errors.js";
4
+ import { createAsyncDisposable } from "../../core/lifecycle.js";
5
+ import { readClaudeCredentials } from "./auth.js";
6
+ const AUTHORITY_DIRECTORY = "claude-credential-authority";
7
+ const LOCK_FILE = "authority.lock";
8
+ const LOCK_CONFLICT_EXIT_CODE = 75;
9
+ const MAXIMUM_DELAY_MS = 2_147_483_647;
10
+ const AUTHORITY_PROMPT = "Reply OK without using tools.";
11
+ function authorityStateDirectory(env) {
12
+ const configuredState = env.XDG_STATE_HOME;
13
+ if (configuredState !== undefined && configuredState.length > 0 && isAbsolute(configuredState)) {
14
+ return join(configuredState, "opencode-ext-connector", AUTHORITY_DIRECTORY);
15
+ }
16
+ const home = env.HOME;
17
+ if (home === undefined || home.length === 0 || !isAbsolute(home))
18
+ return null;
19
+ return join(home, ".local", "state", "opencode-ext-connector", AUTHORITY_DIRECTORY);
20
+ }
21
+ async function createStateDirectory(path) {
22
+ await mkdir(path, { recursive: true, mode: 0o700 });
23
+ }
24
+ function assertNeverProcessExit(exit) {
25
+ throw new InvalidArgumentError("processExit", exit);
26
+ }
27
+ export function createClaudeCredentialAuthorityScheduler(options) {
28
+ if (!Number.isSafeInteger(options.leadMs) || options.leadMs < 0) {
29
+ throw new InvalidArgumentError("leadMs");
30
+ }
31
+ if (!Number.isSafeInteger(options.retryMs) || options.retryMs <= 0) {
32
+ throw new InvalidArgumentError("retryMs");
33
+ }
34
+ if (!options.enabled || (options.platform ?? process.platform) !== "linux") {
35
+ return createAsyncDisposable(() => undefined);
36
+ }
37
+ const controller = new AbortController();
38
+ const readCredentials = options.readCredentials ?? readClaudeCredentials;
39
+ const ensureStateDirectory = options.ensureStateDirectory ?? createStateDirectory;
40
+ const stateDirectory = authorityStateDirectory(options.env);
41
+ let scheduled;
42
+ let activeProcess;
43
+ let activeEvaluation;
44
+ let disposalStarted = false;
45
+ const arm = (delayMs) => {
46
+ if (disposalStarted)
47
+ return;
48
+ scheduled?.cancel();
49
+ scheduled = options.clock.schedule(Math.min(MAXIMUM_DELAY_MS, Math.max(0, Math.trunc(delayMs))), () => {
50
+ scheduled = undefined;
51
+ startEvaluation(true);
52
+ });
53
+ };
54
+ const runAuthority = async () => {
55
+ if (stateDirectory === null)
56
+ return null;
57
+ await ensureStateDirectory(stateDirectory);
58
+ const process = await options.processSupervisor.start({
59
+ executable: "flock",
60
+ arguments: [
61
+ "--exclusive",
62
+ "--nonblock",
63
+ "--conflict-exit-code",
64
+ String(LOCK_CONFLICT_EXIT_CODE),
65
+ "--no-fork",
66
+ "--",
67
+ join(stateDirectory, LOCK_FILE),
68
+ "claude",
69
+ "--restricted",
70
+ "-p",
71
+ AUTHORITY_PROMPT,
72
+ "--permission-prompts",
73
+ "none",
74
+ "--max-turns",
75
+ "1",
76
+ "--output-format",
77
+ "json",
78
+ ],
79
+ cwd: stateDirectory,
80
+ }, controller.signal);
81
+ activeProcess = process;
82
+ try {
83
+ return await process.wait(controller.signal);
84
+ }
85
+ finally {
86
+ activeProcess = undefined;
87
+ await process.dispose();
88
+ }
89
+ };
90
+ const evaluate = async (canInvoke) => {
91
+ const credentials = await readCredentials(options.env, controller.signal);
92
+ if (disposalStarted)
93
+ return;
94
+ const expiresAtMs = credentials?.expiresAtMs ?? null;
95
+ if (expiresAtMs === null) {
96
+ arm(options.retryMs);
97
+ return;
98
+ }
99
+ const untilLeadMs = expiresAtMs - options.leadMs - options.clock.nowMs();
100
+ if (untilLeadMs > 0) {
101
+ arm(untilLeadMs);
102
+ return;
103
+ }
104
+ if (!canInvoke) {
105
+ arm(options.retryMs);
106
+ return;
107
+ }
108
+ const exit = await runAuthority();
109
+ if (disposalStarted)
110
+ return;
111
+ if (exit === null) {
112
+ options.logger.log("warn", "claude.credential-authority.state-unavailable", {});
113
+ arm(options.retryMs);
114
+ return;
115
+ }
116
+ switch (exit.kind) {
117
+ case "signal":
118
+ options.logger.log("warn", "claude.credential-authority.signal-exit", {
119
+ signal: exit.signal,
120
+ });
121
+ arm(options.retryMs);
122
+ return;
123
+ case "code":
124
+ if (exit.code === LOCK_CONFLICT_EXIT_CODE) {
125
+ arm(options.retryMs);
126
+ return;
127
+ }
128
+ if (exit.code !== 0) {
129
+ options.logger.log("warn", "claude.credential-authority.nonzero-exit", {
130
+ exitCode: exit.code,
131
+ });
132
+ arm(options.retryMs);
133
+ return;
134
+ }
135
+ await evaluate(false);
136
+ return;
137
+ default:
138
+ return assertNeverProcessExit(exit);
139
+ }
140
+ };
141
+ const startEvaluation = (canInvoke) => {
142
+ if (disposalStarted || activeEvaluation !== undefined)
143
+ return;
144
+ const operation = evaluate(canInvoke).catch((error) => {
145
+ if (error instanceof OperationCancelledError && disposalStarted)
146
+ return;
147
+ if (error instanceof ProcessSupervisorError) {
148
+ options.logger.log("warn", "claude.credential-authority.process-failed", {});
149
+ arm(options.retryMs);
150
+ return;
151
+ }
152
+ if (error instanceof Error) {
153
+ options.logger.log("warn", "claude.credential-authority.evaluation-failed", {});
154
+ arm(options.retryMs);
155
+ return;
156
+ }
157
+ throw error;
158
+ });
159
+ activeEvaluation = operation;
160
+ void operation.then(() => {
161
+ if (activeEvaluation === operation)
162
+ activeEvaluation = undefined;
163
+ }, () => {
164
+ if (activeEvaluation === operation)
165
+ activeEvaluation = undefined;
166
+ });
167
+ };
168
+ const disposal = createAsyncDisposable(async () => {
169
+ disposalStarted = true;
170
+ scheduled?.cancel();
171
+ scheduled = undefined;
172
+ controller.abort();
173
+ await activeProcess?.terminate();
174
+ await activeEvaluation;
175
+ });
176
+ startEvaluation(true);
177
+ return {
178
+ dispose: disposal.dispose,
179
+ [Symbol.asyncDispose]: disposal[Symbol.asyncDispose],
180
+ };
181
+ }
package/dist/server.js CHANGED
@@ -10,6 +10,8 @@ import { getProductionOllamaBundle } from "./opencode/ollama-production.js";
10
10
  import { createProviderRegistry, selectConfiguredProviders } from "./opencode/providers.js";
11
11
  import { disposeV1LanguageRuntime } from "./opencode/v1-language.js";
12
12
  import { buildV1AuthHooks, createV1AuthServer, createV1Server } from "./opencode/v1-module.js";
13
+ import { createProductionProcessSupervisor } from "./process/production-supervisor.js";
14
+ import { createClaudeCredentialAuthorityScheduler } from "./providers/claude/credential-authority-scheduler.js";
13
15
  import { writeClaudeCredentials } from "./providers/claude/writeback.js";
14
16
  import { productionOllamaFetch } from "./providers/ollama/http.js";
15
17
  const env = process.env;
@@ -73,14 +75,28 @@ export const connectorServer = async (input, options) => {
73
75
  health: connectorOptions.health,
74
76
  logger,
75
77
  })(input, options);
78
+ const processSupervisor = createProductionProcessSupervisor();
79
+ const claudeCliAuthority = connectorOptions.credentialAuthority.claudeCli;
80
+ const credentialAuthority = createClaudeCredentialAuthorityScheduler({
81
+ enabled: claudeCliAuthority.enabled,
82
+ clock,
83
+ leadMs: claudeCliAuthority.leadMs,
84
+ retryMs: claudeCliAuthority.retryMs,
85
+ env,
86
+ processSupervisor,
87
+ logger,
88
+ });
76
89
  const dispose = hooks.dispose;
77
90
  const disposal = createAsyncDisposable(async () => {
78
- try {
79
- await dispose?.();
80
- }
81
- finally {
82
- await disposeV1LanguageRuntime();
83
- }
91
+ const results = await Promise.allSettled([
92
+ Promise.resolve().then(() => credentialAuthority.dispose()),
93
+ Promise.resolve().then(() => processSupervisor.dispose()),
94
+ Promise.resolve().then(() => dispose?.()),
95
+ Promise.resolve().then(disposeV1LanguageRuntime),
96
+ ]);
97
+ const primaryFailure = results.find((result) => result.status === "rejected");
98
+ if (primaryFailure !== undefined)
99
+ throw primaryFailure.reason;
84
100
  });
85
101
  return {
86
102
  ...hooks,
package/docs/README.ko.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  ## 상태
21
21
 
22
- > 독립적인 비공식 커뮤니티 플러그인, 버전 **0.4.0**. CI에 설치된 OpenCode CLI를 대상으로 legacy multi-function 로더를 패키지 E2E 테스트로 검증합니다. `@opencode-ai/plugin@1.18.18`은 컴파일 시 사용하는 플러그인 API 대상이며 OpenCode 런타임 버전 고정이 아닙니다. 소스는 BSD-3-Clause입니다. 이 프로젝트는 OpenCode 또는 어떤 프로바이더와도 제휴, 보증, 후원, 승인 관계가 없습니다. 전체 조건은 [라이선스 및 면책 조항](#라이선스-및-면책-조항)에 있습니다.
22
+ > 독립적인 비공식 커뮤니티 플러그인, 버전 **0.6.0**. 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,7 +55,7 @@ OpenCode는 시작 시 Bun으로 설정된 npm 플러그인을 설치하고 캐
55
55
  ```jsonc
56
56
  {
57
57
  "$schema": "https://opencode.ai/config.json",
58
- "plugin": ["opencode-ext-connector@0.4.0"]
58
+ "plugin": ["opencode-ext-connector@0.6.0"]
59
59
  }
60
60
  ```
61
61
 
@@ -73,15 +73,47 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
73
73
  | --- | --- | --- |
74
74
  | `providers` | 네 프로바이더 모두 | 등록할 프로바이더 id: `claude`, `cursor`, `command-code`, `ollama`; 명시적 `[]`는 모두 비활성화 |
75
75
  | `ollamaBaseURL` | `"http://localhost:11434"` | 신뢰하는 Ollama 데몬의 절대 `http` 또는 `https` base; 경로 prefix를 보존 |
76
- | `credentialManagement` | 생략 | 권장 권한 정책: `"connector"`는 어댑터가 갱신과 writeback을 모두 지원할 이를 허용하고, `"external"`은 커넥터의 갱신과 writeback을 금지 |
76
+ | `credentialRole` | 생략 | 권장 공유 로그인 설정: `"owner"`는 자격 증명을 소유한 단일 인스턴스에서 Claude CLI 권한을 실행하고, `"reader"`는 외부 관리 자격 증명만 읽음 |
77
+ | `credentialManagement` | 생략 | 고급 정책: `"connector"`는 어댑터가 갱신과 writeback을 모두 지원할 때 이를 허용하고, `"external"`은 커넥터의 갱신과 writeback을 금지 |
77
78
  | `writeBackCredentials` | `false` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 갱신 후 Claude writeback을 제어 |
78
79
  | `credentialRefresh.mode` | `"auto"` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, Claude의 `"auto"` 또는 `"never"` 갱신 동작을 제어 |
79
80
  | `credentialRefresh.leadMs` | `60000` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 사용자 지정 리드 타임에는 여전히 이 레거시 설정이 필요 |
81
+ | `credentialAuthority.claudeCli.enabled` | `false` | Claude 전용 옵트인: [옵트인 Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용) 참조 |
82
+ | `credentialAuthority.claudeCli.leadMs` | `300000` | Claude 전용 옵트인: 자격 증명 만료 전 이 밀리초만큼 남았을 때 타이머가 `claude`를 호출; 음수가 아닌 정수 |
83
+ | `credentialAuthority.claudeCli.retryMs` | `300000` | Claude 전용 옵트인: 0이 아닌 종료, 잠금 충돌, 시그널, supervisor 실패 후 재시도까지의 밀리초; 양의 정수 |
80
84
  | `catalogReloadMs` | `300000` | 이 간격으로 카탈로그 스냅샷을 다시 실행; `0`이면 비활성화 |
81
85
  | `snapshotTimeoutMs` | `30000` | 프로바이더별 스냅샷 기한 |
82
86
  | `health.initialBackoffMs` | `1000` | 스냅샷 실패 후 health backoff |
83
87
  | `health.maximumBackoffMs` | `60000` | health backoff 상한 |
84
88
 
89
+ ### 자격 증명 소유권 (권장)
90
+
91
+ 공유 Claude Code 로그인을 소유하고 갱신하는 단일 Linux 인스턴스에서 `"owner"`를 선택하십시오:
92
+
93
+ ```jsonc
94
+ {
95
+ "$schema": "https://opencode.ai/config.json",
96
+ "plugin": [["opencode-ext-connector", { "credentialRole": "owner" }]]
97
+ }
98
+ ```
99
+
100
+ 동기화된 자격 증명만 소비하는 모든 인스턴스에서는 `"reader"`를 선택하십시오:
101
+
102
+ ```jsonc
103
+ {
104
+ "$schema": "https://opencode.ai/config.json",
105
+ "plugin": [["opencode-ext-connector", { "credentialRole": "reader" }]]
106
+ }
107
+ ```
108
+
109
+ `"owner"`는 내부적으로 external 자격 증명 관리를 선택하고 기본 타이밍으로 Claude CLI 권한을 활성화합니다. `"reader"`는 CLI 권한을 시작하지 않고 external 자격 증명 관리를 선택합니다. 이 역할은 자격 증명 소유권을 뜻하며 OpenCode가 물리 호스트에서 실행되는지, 샌드박스 게스트에서 실행되는지를 뜻하지 않습니다.
110
+
111
+ 공유 Claude 로그인마다 owner를 정확히 하나만 설정하십시오. Owner 모드에는 Linux, util-linux `flock`, `PATH`의 Claude Code `2.1.259` 이상, 인증된 Claude Code 세션이 필요합니다. 각 권한 호출은 실제 모델 요청이며 계정 사용량을 소비할 수 있습니다. 폐기된 로그인은 여전히 대화형 `/login`이 필요합니다.
112
+
113
+ `credentialRole`을 `credentialManagement`, `credentialAuthority`, `credentialRefresh`, `writeBackCredentials`와 함께 사용하지 마십시오. 기존 저수준 설정은 고급 제어용으로 계속 지원됩니다.
114
+
115
+ ### 고급 자격 증명 정책
116
+
85
117
  `credentialManagement: "connector"`는 프로바이더 어댑터가 갱신과 writeback을 모두 지원하는 경우 커넥터에 그 권한을 부여합니다. 현재 이 기능을 지원하는 것은 Claude뿐이며, 만료 `60_000`ms 전 자동 갱신과 writeback 활성화로 매핑됩니다:
86
118
 
87
119
  ```jsonc
@@ -114,7 +146,7 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
114
146
  }
115
147
  ```
116
148
 
117
- `credentialManagement: "external"`은 커넥터의 갱신과 writeback을 금지합니다. 현재 Claude에서는 갱신 안 함/writeback 안 함으로 매핑되며, 401 이후 외부에서 관리되는 자격 증명을 다시 읽을 수 있습니다. 모든 자격 증명 정책 옵션을 생략하면 Claude는 레거시 기본 동작을 유지합니다. 즉, `60_000`ms 리드 타임으로 자동 갱신하지만 writeback하지 않습니다. `credentialManagement`만 생략한 경우에는 제공된 사용 중단 예정 `credentialRefresh` 또는 `writeBackCredentials` 옵션이 계속 동작을 제어합니다. 두 모드 모두 Cursor direct 생성과 Command Code는 읽기 전용입니다. 정확한 HTTP 401이 출력이나 효과 전에 발생하면 null이 아니며 변경된 자격 증명을 다시 읽고 한 번만 재시도합니다. 이 안전한 다시 읽기는 갱신이나 writeback이 아니므로 두 모드에서 모두 허용되며, Cursor legacy/compatibility 생성은 한 번만 시도합니다. Ollama에는 영향이 없습니다. 이 옵션은 로그인하거나 OAuth를 발급하지 않고, 머신 간 동기화를 수행하지 않으며, 자격 증명이 파일에 저장된다는 의미도 아닙니다.
149
+ `credentialManagement: "external"`은 커넥터의 갱신과 writeback을 금지합니다. 현재 Claude에서는 갱신 안 함/writeback 안 함으로 매핑되며, 401 이후 외부에서 관리되는 자격 증명을 다시 읽을 수 있습니다. 단일 Linux 호스트에서 외부 관리 자격 증명 파일을 최신 상태로 유지하려면 Claude 전용 [옵트인 Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용)를 추가로 활성화할 수 있습니다. 그 타이머는 커넥터 자체의 갱신 경로와 독립적이며 기본값은 꺼짐입니다. 모든 자격 증명 정책 옵션을 생략하면 Claude는 레거시 기본 동작을 유지합니다. 즉, `60_000`ms 리드 타임으로 자동 갱신하지만 writeback하지 않습니다. `credentialManagement`만 생략한 경우에는 제공된 사용 중단 예정 `credentialRefresh` 또는 `writeBackCredentials` 옵션이 계속 동작을 제어합니다. 두 모드 모두 Cursor direct 생성과 Command Code는 읽기 전용입니다. 정확한 HTTP 401이 출력이나 효과 전에 발생하면 null이 아니며 변경된 자격 증명을 다시 읽고 한 번만 재시도합니다. 이 안전한 다시 읽기는 갱신이나 writeback이 아니므로 두 모드에서 모두 허용되며, Cursor legacy/compatibility 생성은 한 번만 시도합니다. Ollama에는 영향이 없습니다. 이 옵션은 로그인하거나 OAuth를 발급하지 않고, 머신 간 동기화를 수행하지 않으며, 자격 증명이 파일에 저장된다는 의미도 아닙니다.
118
150
 
119
151
  한 번의 마이그레이션 주기 동안 `writeBackCredentials`와 `credentialRefresh.*`는 `credentialManagement` 없이 사용할 때 계속 허용됩니다. 사용자 지정 `credentialRefresh.leadMs` 값에는 여전히 레거시 설정이 필요합니다. 새 옵션을 레거시 옵션 중 하나와 함께 사용하면 다음 정확한 메시지와 함께 거부됩니다: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``.
120
152
 
@@ -140,12 +172,45 @@ OpenCode는 인스턴스 구성 중에 활성 프로바이더 레지스트리를
140
172
 
141
173
  Anthropic은 갱신할 때마다 refresh 토큰을 회전시키고 이전 토큰을 무효화합니다. 따라서 `~/.claude/.credentials.json` 사본 두 개가 각자 갱신하면 서로를 깨뜨립니다. 파일 복사는 정확히 한 머신만 갱신하고, 나머지 머신이 자기 사본이 만료되기 전에 그 결과를 받을 때만 동작합니다:
142
174
 
143
- - **갱신 권한 머신** (로그인한 곳): `credentialManagement: "connector"`. 사용자 지정 배포 여유 시간이 필요하면 이번 마이그레이션 주기에는 사용 중단 예정인 레거시 옵션만 사용하십시오. 예: `writeBackCredentials: true`와 `credentialRefresh: { mode: "auto", leadMs: 1800000 }`.
144
- - **외부 권한 머신**: `credentialManagement: "external"`. OAuth 엔드포인트에 접속하지 않으며, 요청이 401을 반환하면 외부에서 관리되는 자격 증명을 다시 읽고 한 번 재시도합니다.
175
+ - **자격 증명 owner** (로그인한 곳): 단일 Linux 인스턴스에서 `credentialRole: "owner"`를 사용하십시오. 고급 제어가 필요하면 `credentialManagement: "connector"`를 사용하거나 `credentialManagement: "external"`과 옵트인 [Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용)를 결합하십시오.
176
+ - **자격 증명 reader**: `credentialRole: "reader"`를 사용하십시오. OAuth 엔드포인트에 접속하지 않으며, 요청이 401을 반환하면 외부에서 관리되는 자격 증명을 다시 읽고 한 번 재시도합니다.
145
177
  - 갱신 권한 머신의 외부 관리 자격 증명 자료가 바뀔 때마다 동기화하십시오. 이 옵션 자체는 머신을 동기화하거나 파일 저장을 요구하지 않습니다. `~/.claude/.credentials.json`을 복사하는 경우 OpenCode 자체 `auth.json`에는 `anthropic` 레코드만 한 번 있으면 되며 다른 프로바이더는 건드리지 마십시오.
146
178
 
147
179
  대화형으로 사용하는 Claude Code 설치처럼 스스로 갱신하는 머신은 파일을 공유하면 안 됩니다. 그곳에서는 별도로 로그인하십시오.
148
180
 
181
+ ### 옵트인 Claude CLI 권한 타이머 (Claude 전용)
182
+
183
+ 이 옵션은 사용자 지정 `leadMs` 또는 `retryMs`가 필요할 때 쓰는 `credentialRole: "owner"`의 고급 형태입니다. 기본값은 꺼짐이며, 로그인하거나 토큰을 발급하지 않고 `credentialManagement: "external"`이 필요합니다.
184
+
185
+ 저수준 등가 설정:
186
+
187
+ ```jsonc
188
+ {
189
+ "$schema": "https://opencode.ai/config.json",
190
+ "plugin": [
191
+ [
192
+ "opencode-ext-connector",
193
+ {
194
+ "credentialManagement": "external",
195
+ "credentialAuthority": {
196
+ "claudeCli": {
197
+ "enabled": true
198
+ }
199
+ }
200
+ }
201
+ ]
202
+ ]
203
+ }
204
+ ```
205
+
206
+ 설정을 저장한 뒤 OpenCode를 완전히 재시작하십시오. 타이머는 Linux, `PATH`의 util-linux `flock`, Claude Code `2.1.259` 이상, 인증된 Claude Code 세션, writable persistent 상태 디렉터리가 필요합니다. 다른 플랫폼에서는 조용히 비활성화됩니다.
207
+
208
+ `leadMs`와 `retryMs`의 기본값은 모두 `300000`(5분)입니다. 커넥터는 lead 경계에서 restricted single-turn Claude 요청 하나를 예약합니다. 프로세스 공유 non-blocking lock이 같은 상태 디렉터리를 사용하는 프로세스의 동시 요청을 막고, 실패하면 Claude 프로바이더를 제거하지 않은 채 `retryMs` 후 재시도합니다.
209
+
210
+ 각 호출은 실제 모델 요청이며 계정의 구독 또는 청구 사용량에 포함될 수 있습니다. 로그인 하나당 정확히 한 머신에서만 타이머를 실행하십시오. 폐기되거나 로그아웃된 세션은 복구할 수 없으므로 그 경우 대화형 `/login`을 다시 실행해야 합니다. 활성화 전에 Anthropic의 현재 [Commercial Terms](https://www.anthropic.com/legal/commercial-terms)를 검토하십시오.
211
+
212
+ 타이머가 실행되지 않으면 `flock --version`, `claude --version`, 상태 디렉터리 권한, 두 필수 설정값을 확인하십시오. 반복 경고는 CLI 실행 실패, 시그널 종료, 또는 시작 실패를 뜻합니다. 비활성화하려면 `credentialAuthority`를 제거하거나 `enabled`를 `false`로 설정한 뒤 OpenCode를 완전히 재시작하십시오.
213
+
149
214
  ## 호스트/게스트 샌드박스 설정
150
215
 
151
216
  OpenCode가 컨테이너, VM 또는 다른 샌드박스에서 실행될 때 그 런타임을 **게스트**, 벤더 로그인과 Ollama 데몬을 소유한 머신을 **호스트**로 봅니다. 게스트에는 자체 `localhost`, 홈 디렉터리, 환경, keychain, 파일 권한, 네트워크 namespace가 있습니다. 파일을 mount하거나 환경 값을 명시적으로 주입하지 않으면 호스트 세션은 게스트에 보이지 않습니다.
@@ -154,7 +219,7 @@ OpenCode가 컨테이너, VM 또는 다른 샌드박스에서 실행될 때 그
154
219
 
155
220
  | 프로바이더 | 호스트 | 게스트 |
156
221
  | --- | --- | --- |
157
- | Claude | Claude Code 로그인을 소유하고 갱신 | Claude 자격 증명 디렉터리를 read-only로 mount하고 `CLAUDE_CONFIG_DIR`을 그 게스트 경로로 설정하며 `credentialManagement: "external"`을 사용; 호스트 macOS Keychain은 Linux 게스트 안에서 사용할 수 없음; `ANTHROPIC_CLI_VERSION`, 설치된 `claude` 바이너리 또는 npm registry 접근으로 클라이언트 버전 확인 |
222
+ | Claude | Claude Code 로그인을 소유하고 갱신 | Claude 자격 증명 디렉터리를 read-only로 mount하고 `CLAUDE_CONFIG_DIR`을 그 게스트 경로로 설정하며 `credentialRole: "reader"`를 사용; 호스트 macOS Keychain은 Linux 게스트 안에서 사용할 수 없음; `ANTHROPIC_CLI_VERSION`, 설치된 `claude` 바이너리 또는 npm registry 접근으로 클라이언트 버전 확인 |
158
223
  | Cursor | Cursor CLI 로그인을 소유 | 자격 증명 파일을 게스트의 `${HOME}/.config/cursor/auth.json`에 read-only로 mount하거나 샌드박스 secret 기능으로 `CURSOR_ACCESS_TOKEN` 주입; 게스트에 Node.js 22 이상 설치 |
159
224
  | Command Code | CLI 로그인 또는 API 키를 소유 | `${HOME}/.commandcode/auth.json`을 read-only로 mount하거나 `COMMAND_CODE_API_KEY` 주입; `COMMAND_CODE_CLI_VERSION`, 설치된 `command-code` 바이너리 또는 npm registry 접근으로 클라이언트 버전 확인 |
160
225
  | Ollama | 신뢰하는 데몬을 실행하고 Cloud 접근이 필요하면 그곳에서 `ollama signin` 실행 | Ollama 자격 증명을 복사하지 않고 `ollamaBaseURL`로 선택한 데몬에만 연결 |
@@ -184,7 +249,7 @@ COMMAND_CODE_CLI_VERSION=<optional-compatible-version>
184
249
  {
185
250
  "providers": ["claude", "cursor", "command-code", "ollama"],
186
251
  "ollamaBaseURL": "http://host.docker.internal:11434",
187
- "credentialManagement": "external",
252
+ "credentialRole": "reader",
188
253
  "catalogReloadMs": 300000,
189
254
  "snapshotTimeoutMs": 30000,
190
255
  "health": {
@@ -197,7 +262,7 @@ COMMAND_CODE_CLI_VERSION=<optional-compatible-version>
197
262
  }
198
263
  ```
199
264
 
200
- `ollamaBaseURL`은 OpenCode 프로바이더 옵션이 아니라 패키지 tuple에 넣는 flat 커넥터 옵션입니다. 위 숫자 값은 커넥터 기본값이고, `credentialManagement: "external"`과 호스트 데몬 URL은 read-only 호스트 소유 자격 증명을 위한 의도적인 override입니다. `opencode.json`에 벤더 토큰을 넣지 말고 read-only mount 또는 샌드박스 secret 주입 기능을 사용하십시오.
265
+ `ollamaBaseURL`은 OpenCode 프로바이더 옵션이 아니라 패키지 tuple에 넣는 flat 커넥터 옵션입니다. 위 숫자 값은 커넥터 기본값이고, `credentialRole: "reader"`와 호스트 데몬 URL은 read-only 호스트 소유 자격 증명을 위한 의도적인 override입니다. `opencode.json`에 벤더 토큰을 넣지 말고 read-only mount 또는 샌드박스 secret 주입 기능을 사용하십시오.
201
266
 
202
267
  Docker Desktop에서는 보통 `host.docker.internal`이 호스트로 resolve됩니다. Linux Docker bridge에는 `--add-host=host.docker.internal:host-gateway` 또는 다음 Compose 설정이 추가로 필요할 수 있습니다:
203
268
 
@@ -208,7 +273,7 @@ extra_hosts:
208
273
 
209
274
  Ollama는 보통 호스트 loopback에서 수신합니다. Bridge networking에서는 호스트가 `OLLAMA_HOST=0.0.0.0:11434`로 시작해야 할 수 있으며, 노출된 port를 호스트 firewall과 샌드박스 network policy로 제한하십시오. `OLLAMA_HOST`는 호스트 데몬을 설정하고 `ollamaBaseURL`은 게스트의 이 커넥터를 설정합니다. Host networking을 사용하면 게스트 `localhost`가 호스트에 도달하지만 격리가 약해지므로 명시적으로 선택해야 합니다. 다른 샌드박스 런타임도 이에 해당하는 호스트 route가 필요하며, 활성화한 각 프로바이더로 outbound 접근을 허용해야 합니다. Claude 또는 Command Code가 환경 값이나 설치된 바이너리에서 클라이언트 버전을 확인할 수 없을 때만 `registry.npmjs.org` 접근을 허용하십시오.
210
275
 
211
- 대신 게스트가 persistent guest storage에서 자체 벤더 로그인을 소유할 수도 있습니다. 이 모드에서는 호스트 자격 증명을 mount하지 말고 게스트에서 벤더 로그인 flow를 실행하십시오. 게스트가 유일한 Claude 갱신 소유자라면 `credentialManagement: "connector"`를 사용할 수 있습니다. 호스트와 게스트가 같은 Claude refresh token에서 파생된 자격 증명을 각자 갱신하게 해서는 안 됩니다.
276
+ 대신 게스트가 persistent guest storage에서 자체 벤더 로그인을 소유할 수도 있습니다. 이 모드에서는 호스트 자격 증명을 mount하지 말고 게스트에서 벤더 로그인 flow를 실행하십시오. Linux 게스트가 유일한 Claude 갱신 owner라면 `credentialRole: "owner"`를 사용할 수 있으며, 커넥터가 OAuth 갱신과 writeback을 관리해야 할 때는 저수준 `credentialManagement: "connector"`도 계속 사용할 수 있습니다. 호스트와 게스트가 같은 Claude refresh token에서 파생된 자격 증명을 각자 갱신하게 해서는 안 됩니다.
212
277
 
213
278
  ## 업데이트 및 제거
214
279
 
@@ -242,7 +307,7 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
242
307
 
243
308
  | 프로바이더 | 하는 일 |
244
309
  | --- | --- |
245
- | **Claude** | 기존 Claude Code 자격 증명을 재사용합니다. OAuth를 발급하지 않습니다. 호환 fetch가 CLI 호환 요청 메타데이터를 보내고, 내장 `anthropic` 경로에서 Anthropic SSE를 스트림합니다. `credentialManagement: "connector"`는 `60_000`ms 리드 타임의 자동 갱신과 writeback으로, `"external"`은 갱신 안 함/writeback 안 및 401 이후 자격 증명 다시 읽기로 매핑됩니다. 모든 자격 증명 정책 옵션을 생략하면 레거시 자동 갱신/`60_000` 동작과 writeback 안 함이 유지되며, `credentialManagement`만 생략하면 제공된 사용 중단 예정 옵션이 계속 동작을 제어합니다. |
310
+ | **Claude** | 기존 Claude Code 자격 증명을 재사용합니다. OAuth를 발급하지 않습니다. 호환 fetch가 CLI 호환 요청 메타데이터를 보내고, 내장 `anthropic` 경로에서 Anthropic SSE를 스트림합니다. 공유 로그인에서는 `credentialRole: "owner"`가 단일 인스턴스의 Linux 전용 Claude CLI 권한을 활성화하고, `"reader"`는 나머지 인스턴스를 읽기 전용으로 유지하면서 401 이후 자격 증명을 다시 읽습니다. 저수준 `credentialManagement: "connector"`는 `60_000`ms 리드 타임의 자동 갱신과 writeback으로, `"external"`은 갱신 안 함/writeback 안 함으로 매핑됩니다. 모든 자격 증명 정책 옵션을 생략하면 레거시 자동 갱신/`60_000` 동작과 writeback 안 함이 유지되며, `credentialManagement`만 생략하면 제공된 사용 중단 예정 옵션이 계속 동작을 제어합니다. |
246
311
  | **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`입니다. |
247
312
  | **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`입니다. |
248
313
  | **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를 사용하지 않습니다. |
@@ -258,8 +323,8 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
258
323
  | `/connect` 메서드가 없음 | `plugin`에 `"opencode-ext-connector"` 또는 정확한 공개 `"opencode-ext-connector@<version>"` spec이 있는지 확인한 뒤 OpenCode를 완전히 재시작하십시오. |
259
324
  | 프로바이더가 활성화됐지만 모델이 없음 | `providers`를 생략하면 네 프로바이더가 모두 활성화됩니다. 명시적 목록은 엄격한 allow-list입니다. Claude와 Cursor는 마커 또는 OAuth 레코드와 벤더 세션이 필요하고, Command Code는 OpenCode에 저장된 API 키 또는 CLI 세션/키를 사용할 수 있으며, Ollama는 정확한 마커와 응답하는 설정된 데몬이 필요합니다. `/connect` 후 완전히 재시작해야 인스턴스 재생성이 새 소속을 반영합니다. |
260
325
  | Claude가 다음 시작 전까지만 동작함 | 모든 자격 증명 정책 옵션을 생략하면 레거시의 메모리 내 갱신과 writeback 안 함이 유지됩니다. 회전된 refresh 토큰은 다음 프로세스 시작에서 실패할 수 있습니다. 커넥터가 갱신하고 기록해야 한다면 `credentialManagement: "connector"`를 사용하십시오. `credentialManagement`만 생략했다면 제공된 사용 중단 예정 갱신/writeback 옵션을 확인하십시오. |
261
- | 공유 자격 증명에서 Claude가 `invalid_grant`를 보고함 | 같은 로그인을 쓰는 다른 머신이 이미 갱신해서 refresh 토큰이 회전됐습니다. 머신에 `credentialManagement: "connector"`로 갱신 권한을 주고 나머지에는 `"external"`을 사용하거나, 별도로 로그인하십시오. |
262
- | 설정이 자격 증명 옵션을 거부함 | 옵션과 레거시 옵션을 함께 사용하지 마십시오. 정확한 오류는 다음과 같습니다: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``. 레거시 옵션은 번의 마이그레이션 주기 동안 단독으로 계속 허용됩니다. |
326
+ | 공유 자격 증명에서 Claude가 `invalid_grant`를 보고함 | 같은 로그인을 쓰는 다른 머신이 이미 갱신해서 refresh 토큰이 회전됐습니다. 단일 Linux 인스턴스에 `credentialRole: "owner"`를, 나머지 모든 인스턴스에 `"reader"`를 설정하거나 문서화된 저수준 권한 경로를 정확히 하나만 사용하십시오. 커넥터 OAuth 갱신과 Claude CLI 타이머를 포함해 모든 갱신자가 집계됩니다. |
327
+ | 설정이 자격 증명 옵션을 거부함 | `credentialRole`을 어떤 저수준 자격 증명 옵션과도 함께 사용하지 마십시오. 정확한 역할 충돌 오류는 다음과 같습니다: `` `credentialRole` cannot be combined with `credentialManagement`, `credentialAuthority`, `credentialRefresh`, or `writeBackCredentials` ``. 저수준 옵션은 단독으로 계속 허용되며, `credentialManagement`는 여전히 사용 중단 예정인 갱신/writeback 옵션과 함께 사용할 수 없습니다. |
263
328
  | `Claude Code client version is unavailable` | `ANTHROPIC_CLI_VERSION`도, `claude` 바이너리도 없고 `registry.npmjs.org`에 접근할 수 없었습니다. 변수를 설정하거나 registry 접근을 허용하십시오. |
264
329
  | Cursor 생성이 실패함 | Node.js 22 이상이 필요합니다. 생성은 `cursor-agent`가 아니라 private Node 자식 프로세스를 통한 미공개 프로토콜을 사용합니다. 프로토콜이 어긋나면 해당 프로바이더가 실패하며, 암시적 fallback은 없습니다. |
265
330
  | Command Code 생성이 실패함 | 클라이언트 버전을 확인할 수 없었습니다: `COMMAND_CODE_CLI_VERSION`을 설정하거나, `command-code`를 설치하거나, `registry.npmjs.org` 접근을 허용하십시오. 요청 메타데이터에는 Node.js 버전, 플랫폼, 아키텍처, 절대 작업 디렉터리가 포함됩니다. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ext-connector",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "External provider connector for OpenCode",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",