opencode-ext-connector 0.4.0 → 0.5.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 +11 -0
- package/README.md +42 -6
- package/dist/core/options.d.ts +16 -0
- package/dist/core/options.js +32 -0
- package/dist/opencode/host-options.d.ts +2 -1
- package/dist/opencode/host-options.js +3 -0
- package/dist/process/production-supervisor.d.ts +20 -0
- package/dist/process/production-supervisor.js +171 -0
- package/dist/providers/claude/credential-authority-scheduler.d.ts +23 -0
- package/dist/providers/claude/credential-authority-scheduler.js +181 -0
- package/dist/server.js +22 -6
- package/docs/README.ko.md +42 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-09-15
|
|
4
|
+
|
|
5
|
+
- Add the disabled-by-default Claude CLI credential authority for Linux
|
|
6
|
+
deployments using `credentialManagement: "external"`
|
|
7
|
+
- Schedule one restricted Claude Code request before credential expiry, with
|
|
8
|
+
process-shared `flock` coordination, retry handling, and clean shutdown
|
|
9
|
+
- Require util-linux `flock` and Claude Code `2.1.259` or later when enabled;
|
|
10
|
+
each invocation is a real model request and may consume account usage
|
|
11
|
+
- Keep OAuth, credential persistence, and other providers unchanged; revoked
|
|
12
|
+
Claude sessions still require interactive `/login`
|
|
13
|
+
|
|
3
14
|
## 0.4.0 - 2026-09-11
|
|
4
15
|
|
|
5
16
|
- 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.
|
|
22
|
+
> Independent unofficial community plugin, version **0.5.0**. Package E2E tests exercise the legacy multi-function loader with the OpenCode CLI installed in CI. `@opencode-ai/plugin@1.18.18` is the compile-time plugin API target, not a runtime pin. Source is BSD-3-Clause. This project is not affiliated with, endorsed by, sponsored by, or authorized by OpenCode or any provider. Full terms are in [License and Disclaimer](#license-and-disclaimer).
|
|
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.
|
|
58
|
+
"plugin": ["opencode-ext-connector@0.5.0"]
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -77,6 +77,9 @@ Omitted `providers` enables all four. An explicit list is a strict allow-list. E
|
|
|
77
77
|
| `writeBackCredentials` | `false` | **Deprecated:** accepted alone for one migration cycle; controls Claude writeback after refresh |
|
|
78
78
|
| `credentialRefresh.mode` | `"auto"` | **Deprecated:** accepted alone for one migration cycle; controls Claude `"auto"` or `"never"` refresh behavior |
|
|
79
79
|
| `credentialRefresh.leadMs` | `60000` | **Deprecated:** accepted alone for one migration cycle; custom lead times still require this legacy configuration |
|
|
80
|
+
| `credentialAuthority.claudeCli.enabled` | `false` | Claude-only opt-in: see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only) |
|
|
81
|
+
| `credentialAuthority.claudeCli.leadMs` | `300000` | Claude-only opt-in: milliseconds before credential expiry at which the timer invokes `claude`; non-negative integer |
|
|
82
|
+
| `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
83
|
| `catalogReloadMs` | `300000` | Re-run catalog snapshots on this interval; `0` disables |
|
|
81
84
|
| `snapshotTimeoutMs` | `30000` | Per-provider snapshot deadline |
|
|
82
85
|
| `health.initialBackoffMs` | `1000` | Health backoff after a failed snapshot |
|
|
@@ -114,7 +117,7 @@ Use external authority when another process manages credentials:
|
|
|
114
117
|
}
|
|
115
118
|
```
|
|
116
119
|
|
|
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.
|
|
120
|
+
`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
121
|
|
|
119
122
|
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
123
|
|
|
@@ -140,12 +143,45 @@ With all credential-policy options omitted, refreshed Claude tokens stay in memo
|
|
|
140
143
|
|
|
141
144
|
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
145
|
|
|
143
|
-
- **Refresh authority** (where you log in): `credentialManagement: "connector"
|
|
146
|
+
- **Refresh authority** (where you log in): choose exactly one refresh path. Use `credentialManagement: "connector"`, or on Linux use `credentialManagement: "external"` with the opt-in [Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only). The parser rejects enabling the CLI timer in connector mode. If you need a custom publication window for connector mode, use the deprecated legacy options alone for this migration cycle, for example `writeBackCredentials: true` with `credentialRefresh: { mode: "auto", leadMs: 1800000 }`.
|
|
144
147
|
- **External-authority machines**: `credentialManagement: "external"`. They never contact the OAuth endpoint; when a request returns 401 they re-read externally managed credentials and retry once.
|
|
145
148
|
- 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
149
|
|
|
147
150
|
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
151
|
|
|
152
|
+
### Opt-in Claude CLI authority timer (Claude-only)
|
|
153
|
+
|
|
154
|
+
This Linux-only option lets one host use an existing Claude Code login as the refresh authority for an externally managed credential file. It is disabled by default, does not log in or mint tokens, and requires `credentialManagement: "external"`.
|
|
155
|
+
|
|
156
|
+
Minimal configuration:
|
|
157
|
+
|
|
158
|
+
```jsonc
|
|
159
|
+
{
|
|
160
|
+
"$schema": "https://opencode.ai/config.json",
|
|
161
|
+
"plugin": [
|
|
162
|
+
[
|
|
163
|
+
"opencode-ext-connector",
|
|
164
|
+
{
|
|
165
|
+
"credentialManagement": "external",
|
|
166
|
+
"credentialAuthority": {
|
|
167
|
+
"claudeCli": {
|
|
168
|
+
"enabled": true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
`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.
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
If the timer does not run, check `flock --version`, `claude --version`, the state directory permissions, and both required configuration values. Repeated warnings indicate that the CLI failed, was signalled, or could not be started. To disable the timer, remove `credentialAuthority` or set `enabled` to `false`, then fully restart OpenCode.
|
|
184
|
+
|
|
149
185
|
## Host/Guest Sandbox Setup
|
|
150
186
|
|
|
151
187
|
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.
|
|
@@ -242,7 +278,7 @@ Ollama `/connect` probes the configured daemon and stores the exact session mark
|
|
|
242
278
|
|
|
243
279
|
| Provider | What it does |
|
|
244
280
|
| --- | --- |
|
|
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. |
|
|
281
|
+
| **Claude** | Reuses existing Claude Code credentials. Does not mint OAuth. Compatibility fetch sends CLI-compatible request metadata and streams Anthropic SSE on the built-in `anthropic` path. `credentialManagement: "connector"` maps to auto-refresh with a `60_000` ms lead and writeback; `"external"` maps to never-refresh/no-write with a credential re-read after 401. On Linux only, `credentialManagement: "external"` plus the opt-in `credentialAuthority.claudeCli.enabled: true` timer (see [Opt-in Claude CLI authority timer](#opt-in-claude-cli-authority-timer-claude-only)) can keep an externally managed credential file current by invoking the local `claude` CLI; the timer is off by default and does not log in or extend the underlying session lifetime. Omitting all credential-policy options preserves legacy auto/`60_000` behavior without writeback; if only `credentialManagement` is omitted, supplied deprecated options still control behavior. |
|
|
246
282
|
| **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
283
|
| **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
284
|
| **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,7 +294,7 @@ The standalone SDK entry is `opencode-ext-connector/ollama`; pass `{ ollamaBaseU
|
|
|
258
294
|
| `/connect` methods missing | Confirm `plugin` contains `"opencode-ext-connector"` or an exact published `"opencode-ext-connector@<version>"` spec, then fully restart OpenCode. |
|
|
259
295
|
| 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
296
|
| 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.
|
|
297
|
+
| Claude reports `invalid_grant` on shared credentials | Another machine with the same login already refreshed and rotated the refresh token. Every refresher counts: `credentialManagement: "connector"` refreshes through the OAuth endpoint, and the opt-in `credentialAuthority.claudeCli.enabled` timer refreshes through the local `claude` CLI. Pick exactly one authority: either connector mode, or external mode with the CLI timer on one Linux machine. Keep every other machine on `"external"` with the timer disabled, or log in separately. |
|
|
262
298
|
| Configuration rejects credential options | Do not combine the new and legacy options; the exact error is: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``. Legacy options remain accepted alone for one migration cycle. |
|
|
263
299
|
| `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
300
|
| 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. |
|
package/dist/core/options.d.ts
CHANGED
|
@@ -6,10 +6,25 @@ export type CredentialRefreshPolicy = {
|
|
|
6
6
|
readonly mode: CredentialRefreshMode;
|
|
7
7
|
readonly leadMs: number;
|
|
8
8
|
};
|
|
9
|
+
export type ClaudeCliCredentialAuthority = {
|
|
10
|
+
readonly enabled: boolean;
|
|
11
|
+
readonly leadMs: number;
|
|
12
|
+
readonly retryMs: number;
|
|
13
|
+
};
|
|
14
|
+
export type CredentialAuthority = {
|
|
15
|
+
readonly claudeCli: ClaudeCliCredentialAuthority;
|
|
16
|
+
};
|
|
9
17
|
export type ConnectorOptionsInput = {
|
|
10
18
|
readonly providers?: readonly ("claude" | "cursor" | "command-code" | "ollama")[] | undefined;
|
|
11
19
|
readonly snapshotTimeoutMs?: number | undefined;
|
|
12
20
|
readonly credentialManagement?: CredentialManagement | undefined;
|
|
21
|
+
readonly credentialAuthority?: {
|
|
22
|
+
readonly claudeCli: {
|
|
23
|
+
readonly enabled: boolean;
|
|
24
|
+
readonly leadMs?: number | undefined;
|
|
25
|
+
readonly retryMs?: number | undefined;
|
|
26
|
+
};
|
|
27
|
+
} | undefined;
|
|
13
28
|
/** @deprecated Use credentialManagement instead. */
|
|
14
29
|
readonly writeBackCredentials?: boolean | undefined;
|
|
15
30
|
/** @deprecated Use credentialManagement instead. */
|
|
@@ -28,6 +43,7 @@ export type ConnectorOptions = {
|
|
|
28
43
|
readonly snapshotTimeoutMs: number;
|
|
29
44
|
readonly writeBackCredentials: boolean;
|
|
30
45
|
readonly credentialRefresh: CredentialRefreshPolicy;
|
|
46
|
+
readonly credentialAuthority: CredentialAuthority;
|
|
31
47
|
readonly catalogReloadMs: number;
|
|
32
48
|
readonly health: HealthPolicy;
|
|
33
49
|
};
|
package/dist/core/options.js
CHANGED
|
@@ -5,12 +5,26 @@ 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 SafeIntegerSchema = z.number().int().safe();
|
|
8
9
|
const DefaultProviders = [
|
|
9
10
|
"claude",
|
|
10
11
|
"cursor",
|
|
11
12
|
"command-code",
|
|
12
13
|
"ollama",
|
|
13
14
|
];
|
|
15
|
+
const CredentialAuthorityInputSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
claudeCli: z
|
|
18
|
+
.object({
|
|
19
|
+
enabled: z.boolean(),
|
|
20
|
+
leadMs: SafeIntegerSchema.nonnegative().optional(),
|
|
21
|
+
retryMs: SafeIntegerSchema.positive().optional(),
|
|
22
|
+
})
|
|
23
|
+
.strict()
|
|
24
|
+
.readonly(),
|
|
25
|
+
})
|
|
26
|
+
.strict()
|
|
27
|
+
.readonly();
|
|
14
28
|
function resolveCredentialOptions(input) {
|
|
15
29
|
switch (input.credentialManagement) {
|
|
16
30
|
case undefined:
|
|
@@ -38,6 +52,7 @@ const ConnectorOptionsInputSchema = z
|
|
|
38
52
|
providers: z.array(ProviderSchema).optional(),
|
|
39
53
|
snapshotTimeoutMs: PositiveSafeIntegerSchema.optional(),
|
|
40
54
|
credentialManagement: CredentialManagementSchema.optional(),
|
|
55
|
+
credentialAuthority: CredentialAuthorityInputSchema.optional(),
|
|
41
56
|
writeBackCredentials: z.boolean().optional(),
|
|
42
57
|
credentialRefresh: z
|
|
43
58
|
.object({
|
|
@@ -65,6 +80,15 @@ const ConnectorOptionsInputSchema = z
|
|
|
65
80
|
message: "`credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials`",
|
|
66
81
|
});
|
|
67
82
|
}
|
|
83
|
+
if (input.credentialAuthority?.claudeCli.enabled === true &&
|
|
84
|
+
(input.credentialManagement !== "external" ||
|
|
85
|
+
!(input.providers ?? DefaultProviders).includes("claude"))) {
|
|
86
|
+
context.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
path: ["credentialAuthority", "claudeCli", "enabled"],
|
|
89
|
+
message: "Claude CLI credential authority requires external credential management and the Claude provider",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
68
92
|
const initialBackoffMs = input.health?.initialBackoffMs ?? 1_000;
|
|
69
93
|
const maximumBackoffMs = input.health?.maximumBackoffMs ?? 60_000;
|
|
70
94
|
if (initialBackoffMs > maximumBackoffMs) {
|
|
@@ -77,11 +101,19 @@ export const ConnectorOptionsSchema = ConnectorOptionsInputSchema.transform((inp
|
|
|
77
101
|
maximumBackoffMs: input.health?.maximumBackoffMs ?? 60_000,
|
|
78
102
|
});
|
|
79
103
|
const credentialOptions = resolveCredentialOptions(input);
|
|
104
|
+
const credentialAuthority = Object.freeze({
|
|
105
|
+
claudeCli: Object.freeze({
|
|
106
|
+
enabled: input.credentialAuthority?.claudeCli.enabled ?? false,
|
|
107
|
+
leadMs: input.credentialAuthority?.claudeCli.leadMs ?? 300_000,
|
|
108
|
+
retryMs: input.credentialAuthority?.claudeCli.retryMs ?? 300_000,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
80
111
|
return Object.freeze({
|
|
81
112
|
providers: Object.freeze(input.providers ?? DefaultProviders),
|
|
82
113
|
snapshotTimeoutMs: input.snapshotTimeoutMs ?? 30_000,
|
|
83
114
|
writeBackCredentials: credentialOptions.writeBackCredentials,
|
|
84
115
|
credentialRefresh: credentialOptions.credentialRefresh,
|
|
116
|
+
credentialAuthority,
|
|
85
117
|
catalogReloadMs: input.catalogReloadMs ?? 300_000,
|
|
86
118
|
health,
|
|
87
119
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
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" | "writeBackCredentials"> & {
|
|
3
|
+
readonly credentialAuthority?: unknown;
|
|
3
4
|
readonly credentialManagement?: unknown;
|
|
4
5
|
readonly credentialRefresh?: unknown;
|
|
5
6
|
readonly writeBackCredentials?: unknown;
|
|
@@ -54,6 +54,9 @@ export function pickConnectorOptionsInput(input) {
|
|
|
54
54
|
...(!("credentialManagement" in input) || input.credentialManagement === undefined
|
|
55
55
|
? {}
|
|
56
56
|
: { credentialManagement: input.credentialManagement }),
|
|
57
|
+
...(!("credentialAuthority" in input) || input.credentialAuthority === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { credentialAuthority: input.credentialAuthority }),
|
|
57
60
|
...(credentialRefresh === undefined ? {} : { credentialRefresh }),
|
|
58
61
|
catalogReloadMs: "catalogReloadMs" in input ? nonNegativeInteger(input.catalogReloadMs) : undefined,
|
|
59
62
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
22
|
+
> 독립적인 비공식 커뮤니티 플러그인, 버전 **0.5.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.
|
|
58
|
+
"plugin": ["opencode-ext-connector@0.5.0"]
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -77,6 +77,9 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
|
|
|
77
77
|
| `writeBackCredentials` | `false` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 갱신 후 Claude writeback을 제어 |
|
|
78
78
|
| `credentialRefresh.mode` | `"auto"` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, Claude의 `"auto"` 또는 `"never"` 갱신 동작을 제어 |
|
|
79
79
|
| `credentialRefresh.leadMs` | `60000` | **사용 중단 예정:** 한 번의 마이그레이션 주기 동안 단독 사용 시 허용되며, 사용자 지정 리드 타임에는 여전히 이 레거시 설정이 필요 |
|
|
80
|
+
| `credentialAuthority.claudeCli.enabled` | `false` | Claude 전용 옵트인: [옵트인 Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용) 참조 |
|
|
81
|
+
| `credentialAuthority.claudeCli.leadMs` | `300000` | Claude 전용 옵트인: 자격 증명 만료 전 이 밀리초만큼 남았을 때 타이머가 `claude`를 호출; 음수가 아닌 정수 |
|
|
82
|
+
| `credentialAuthority.claudeCli.retryMs` | `300000` | Claude 전용 옵트인: 0이 아닌 종료, 잠금 충돌, 시그널, supervisor 실패 후 재시도까지의 밀리초; 양의 정수 |
|
|
80
83
|
| `catalogReloadMs` | `300000` | 이 간격으로 카탈로그 스냅샷을 다시 실행; `0`이면 비활성화 |
|
|
81
84
|
| `snapshotTimeoutMs` | `30000` | 프로바이더별 스냅샷 기한 |
|
|
82
85
|
| `health.initialBackoffMs` | `1000` | 스냅샷 실패 후 health backoff |
|
|
@@ -114,7 +117,7 @@ OpenCode는 플러그인 옵션을 두 요소 튜플의 두 번째 항목으로
|
|
|
114
117
|
}
|
|
115
118
|
```
|
|
116
119
|
|
|
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를 발급하지 않고, 머신 간 동기화를 수행하지 않으며, 자격 증명이 파일에 저장된다는 의미도 아닙니다.
|
|
120
|
+
`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
121
|
|
|
119
122
|
한 번의 마이그레이션 주기 동안 `writeBackCredentials`와 `credentialRefresh.*`는 `credentialManagement` 없이 사용할 때 계속 허용됩니다. 사용자 지정 `credentialRefresh.leadMs` 값에는 여전히 레거시 설정이 필요합니다. 새 옵션을 레거시 옵션 중 하나와 함께 사용하면 다음 정확한 메시지와 함께 거부됩니다: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``.
|
|
120
123
|
|
|
@@ -140,12 +143,45 @@ OpenCode는 인스턴스 구성 중에 활성 프로바이더 레지스트리를
|
|
|
140
143
|
|
|
141
144
|
Anthropic은 갱신할 때마다 refresh 토큰을 회전시키고 이전 토큰을 무효화합니다. 따라서 `~/.claude/.credentials.json` 사본 두 개가 각자 갱신하면 서로를 깨뜨립니다. 파일 복사는 정확히 한 머신만 갱신하고, 나머지 머신이 자기 사본이 만료되기 전에 그 결과를 받을 때만 동작합니다:
|
|
142
145
|
|
|
143
|
-
- **갱신 권한 머신** (로그인한 곳): `credentialManagement: "connector"
|
|
146
|
+
- **갱신 권한 머신** (로그인한 곳): 정확히 하나의 갱신 경로를 선택하십시오. `credentialManagement: "connector"`를 사용하거나, Linux에서는 `credentialManagement: "external"`과 옵트인 [Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용)를 함께 사용하십시오. 파서는 connector 모드에서 CLI 타이머 활성화를 거부합니다. connector 모드에 사용자 지정 배포 여유 시간이 필요하면 이번 마이그레이션 주기에는 사용 중단 예정인 레거시 옵션만 사용하십시오. 예: `writeBackCredentials: true`와 `credentialRefresh: { mode: "auto", leadMs: 1800000 }`.
|
|
144
147
|
- **외부 권한 머신**: `credentialManagement: "external"`. OAuth 엔드포인트에 접속하지 않으며, 요청이 401을 반환하면 외부에서 관리되는 자격 증명을 다시 읽고 한 번 재시도합니다.
|
|
145
148
|
- 갱신 권한 머신의 외부 관리 자격 증명 자료가 바뀔 때마다 동기화하십시오. 이 옵션 자체는 머신을 동기화하거나 파일 저장을 요구하지 않습니다. `~/.claude/.credentials.json`을 복사하는 경우 OpenCode 자체 `auth.json`에는 `anthropic` 레코드만 한 번 있으면 되며 다른 프로바이더는 건드리지 마십시오.
|
|
146
149
|
|
|
147
150
|
대화형으로 사용하는 Claude Code 설치처럼 스스로 갱신하는 머신은 파일을 공유하면 안 됩니다. 그곳에서는 별도로 로그인하십시오.
|
|
148
151
|
|
|
152
|
+
### 옵트인 Claude CLI 권한 타이머 (Claude 전용)
|
|
153
|
+
|
|
154
|
+
이 Linux 전용 옵션은 단일 호스트의 기존 Claude Code 로그인을 외부 관리 자격 증명 파일의 갱신 권한으로 사용합니다. 기본값은 꺼짐이며, 로그인하거나 토큰을 발급하지 않고 `credentialManagement: "external"`이 필요합니다.
|
|
155
|
+
|
|
156
|
+
최소 설정:
|
|
157
|
+
|
|
158
|
+
```jsonc
|
|
159
|
+
{
|
|
160
|
+
"$schema": "https://opencode.ai/config.json",
|
|
161
|
+
"plugin": [
|
|
162
|
+
[
|
|
163
|
+
"opencode-ext-connector",
|
|
164
|
+
{
|
|
165
|
+
"credentialManagement": "external",
|
|
166
|
+
"credentialAuthority": {
|
|
167
|
+
"claudeCli": {
|
|
168
|
+
"enabled": true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
설정을 저장한 뒤 OpenCode를 완전히 재시작하십시오. 타이머는 Linux, `PATH`의 util-linux `flock`, Claude Code `2.1.259` 이상, 인증된 Claude Code 세션, writable persistent 상태 디렉터리가 필요합니다. 다른 플랫폼에서는 조용히 비활성화됩니다.
|
|
178
|
+
|
|
179
|
+
`leadMs`와 `retryMs`의 기본값은 모두 `300000`(5분)입니다. 커넥터는 lead 경계에서 restricted single-turn Claude 요청 하나를 예약합니다. 프로세스 공유 non-blocking lock이 같은 상태 디렉터리를 사용하는 프로세스의 동시 요청을 막고, 실패하면 Claude 프로바이더를 제거하지 않은 채 `retryMs` 후 재시도합니다.
|
|
180
|
+
|
|
181
|
+
각 호출은 실제 모델 요청이며 계정의 구독 또는 청구 사용량에 포함될 수 있습니다. 로그인 하나당 정확히 한 머신에서만 타이머를 실행하십시오. 폐기되거나 로그아웃된 세션은 복구할 수 없으므로 그 경우 대화형 `/login`을 다시 실행해야 합니다. 활성화 전에 Anthropic의 현재 [Commercial Terms](https://www.anthropic.com/legal/commercial-terms)를 검토하십시오.
|
|
182
|
+
|
|
183
|
+
타이머가 실행되지 않으면 `flock --version`, `claude --version`, 상태 디렉터리 권한, 두 필수 설정값을 확인하십시오. 반복 경고는 CLI 실행 실패, 시그널 종료, 또는 시작 실패를 뜻합니다. 비활성화하려면 `credentialAuthority`를 제거하거나 `enabled`를 `false`로 설정한 뒤 OpenCode를 완전히 재시작하십시오.
|
|
184
|
+
|
|
149
185
|
## 호스트/게스트 샌드박스 설정
|
|
150
186
|
|
|
151
187
|
OpenCode가 컨테이너, VM 또는 다른 샌드박스에서 실행될 때 그 런타임을 **게스트**, 벤더 로그인과 Ollama 데몬을 소유한 머신을 **호스트**로 봅니다. 게스트에는 자체 `localhost`, 홈 디렉터리, 환경, keychain, 파일 권한, 네트워크 namespace가 있습니다. 파일을 mount하거나 환경 값을 명시적으로 주입하지 않으면 호스트 세션은 게스트에 보이지 않습니다.
|
|
@@ -242,7 +278,7 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
|
|
|
242
278
|
|
|
243
279
|
| 프로바이더 | 하는 일 |
|
|
244
280
|
| --- | --- |
|
|
245
|
-
| **Claude** | 기존 Claude Code 자격 증명을 재사용합니다. OAuth를 발급하지 않습니다. 호환 fetch가 CLI 호환 요청 메타데이터를 보내고, 내장 `anthropic` 경로에서 Anthropic SSE를 스트림합니다. `credentialManagement: "connector"`는 `60_000`ms 리드 타임의 자동 갱신과 writeback으로, `"external"`은 갱신 안 함/writeback 안 함 및 401 이후 자격 증명 다시 읽기로 매핑됩니다. 모든 자격 증명 정책 옵션을 생략하면 레거시 자동 갱신/`60_000` 동작과 writeback 안 함이 유지되며, `credentialManagement`만 생략하면 제공된 사용 중단 예정 옵션이 계속 동작을 제어합니다. |
|
|
281
|
+
| **Claude** | 기존 Claude Code 자격 증명을 재사용합니다. OAuth를 발급하지 않습니다. 호환 fetch가 CLI 호환 요청 메타데이터를 보내고, 내장 `anthropic` 경로에서 Anthropic SSE를 스트림합니다. `credentialManagement: "connector"`는 `60_000`ms 리드 타임의 자동 갱신과 writeback으로, `"external"`은 갱신 안 함/writeback 안 함 및 401 이후 자격 증명 다시 읽기로 매핑됩니다. Linux에서만 `credentialManagement: "external"`에 옵트인 `credentialAuthority.claudeCli.enabled: true` 타이머([옵트인 Claude CLI 권한 타이머](#옵트인-claude-cli-권한-타이머-claude-전용) 참조)를 더해 로컬 `claude` CLI를 호출함으로써 외부 관리 자격 증명 파일을 최신 상태로 유지할 수 있습니다. 이 타이머는 기본값이 꺼져 있으며 로그인하지도 기반 세션 수명을 연장하지도 않습니다. 모든 자격 증명 정책 옵션을 생략하면 레거시 자동 갱신/`60_000` 동작과 writeback 안 함이 유지되며, `credentialManagement`만 생략하면 제공된 사용 중단 예정 옵션이 계속 동작을 제어합니다. |
|
|
246
282
|
| **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
283
|
| **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
284
|
| **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,7 +294,7 @@ Ollama `/connect`는 설정된 데몬을 조사하고 정확한 세션 마커를
|
|
|
258
294
|
| `/connect` 메서드가 없음 | `plugin`에 `"opencode-ext-connector"` 또는 정확한 공개 `"opencode-ext-connector@<version>"` spec이 있는지 확인한 뒤 OpenCode를 완전히 재시작하십시오. |
|
|
259
295
|
| 프로바이더가 활성화됐지만 모델이 없음 | `providers`를 생략하면 네 프로바이더가 모두 활성화됩니다. 명시적 목록은 엄격한 allow-list입니다. Claude와 Cursor는 마커 또는 OAuth 레코드와 벤더 세션이 필요하고, Command Code는 OpenCode에 저장된 API 키 또는 CLI 세션/키를 사용할 수 있으며, Ollama는 정확한 마커와 응답하는 설정된 데몬이 필요합니다. `/connect` 후 완전히 재시작해야 인스턴스 재생성이 새 소속을 반영합니다. |
|
|
260
296
|
| Claude가 다음 시작 전까지만 동작함 | 모든 자격 증명 정책 옵션을 생략하면 레거시의 메모리 내 갱신과 writeback 안 함이 유지됩니다. 회전된 refresh 토큰은 다음 프로세스 시작에서 실패할 수 있습니다. 커넥터가 갱신하고 기록해야 한다면 `credentialManagement: "connector"`를 사용하십시오. `credentialManagement`만 생략했다면 제공된 사용 중단 예정 갱신/writeback 옵션을 확인하십시오. |
|
|
261
|
-
| 공유 자격 증명에서 Claude가 `invalid_grant`를 보고함 | 같은 로그인을 쓰는 다른 머신이 이미 갱신해서 refresh 토큰이 회전됐습니다.
|
|
297
|
+
| 공유 자격 증명에서 Claude가 `invalid_grant`를 보고함 | 같은 로그인을 쓰는 다른 머신이 이미 갱신해서 refresh 토큰이 회전됐습니다. 모든 갱신자가 집계됩니다. `credentialManagement: "connector"`는 OAuth 엔드포인트를 통해 갱신하고, 옵트인 `credentialAuthority.claudeCli.enabled` 타이머는 로컬 `claude` CLI를 통해 갱신합니다. connector 모드 또는 단일 Linux 머신의 external 모드와 CLI 타이머 중 정확히 하나만 권한으로 선택하십시오. 나머지 모든 머신은 타이머가 비활성화된 `"external"`로 유지하거나 각 머신에서 별도로 로그인하십시오. |
|
|
262
298
|
| 설정이 자격 증명 옵션을 거부함 | 새 옵션과 레거시 옵션을 함께 사용하지 마십시오. 정확한 오류는 다음과 같습니다: `` `credentialManagement` cannot be combined with deprecated `credentialRefresh` or `writeBackCredentials` ``. 레거시 옵션은 한 번의 마이그레이션 주기 동안 단독으로 계속 허용됩니다. |
|
|
263
299
|
| `Claude Code client version is unavailable` | `ANTHROPIC_CLI_VERSION`도, `claude` 바이너리도 없고 `registry.npmjs.org`에 접근할 수 없었습니다. 변수를 설정하거나 registry 접근을 허용하십시오. |
|
|
264
300
|
| Cursor 생성이 실패함 | Node.js 22 이상이 필요합니다. 생성은 `cursor-agent`가 아니라 private Node 자식 프로세스를 통한 미공개 프로토콜을 사용합니다. 프로토콜이 어긋나면 해당 프로바이더가 실패하며, 암시적 fallback은 없습니다. |
|