@bitkyc08/opencodex 2.39.0 → 2.40.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/README.md +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -26,6 +26,12 @@ export interface GatewayModelCacheRefreshOptions {
|
|
|
26
26
|
configDir?: string;
|
|
27
27
|
admissionConfig?: Pick<OcxConfig, "apiKeys">;
|
|
28
28
|
env?: NodeJS.ProcessEnv;
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GatewayModelTarget {
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
admissionToken: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
|
|
@@ -70,6 +76,14 @@ function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
|
|
|
70
76
|
/** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
|
|
71
77
|
export async function refreshGatewayModelCacheFromProxy(
|
|
72
78
|
port: number,
|
|
79
|
+
options?: GatewayModelCacheRefreshOptions,
|
|
80
|
+
): Promise<string | null>;
|
|
81
|
+
export async function refreshGatewayModelCacheFromProxy(
|
|
82
|
+
target: GatewayModelTarget,
|
|
83
|
+
options?: GatewayModelCacheRefreshOptions,
|
|
84
|
+
): Promise<string | null>;
|
|
85
|
+
export async function refreshGatewayModelCacheFromProxy(
|
|
86
|
+
portOrTarget: number | GatewayModelTarget,
|
|
73
87
|
options: GatewayModelCacheRefreshOptions = {},
|
|
74
88
|
): Promise<string | null> {
|
|
75
89
|
try {
|
|
@@ -82,12 +96,18 @@ export async function refreshGatewayModelCacheFromProxy(
|
|
|
82
96
|
const configuredToken = options.admissionConfig?.apiKeys
|
|
83
97
|
?.find(entry => entry.key.trim().length > 0)
|
|
84
98
|
?.key.trim();
|
|
85
|
-
const admissionToken =
|
|
99
|
+
const admissionToken = typeof portOrTarget === "number"
|
|
100
|
+
? envToken || serviceFileToken(options.env ?? process.env) || configuredToken
|
|
101
|
+
: portOrTarget.admissionToken;
|
|
86
102
|
if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
|
|
87
103
|
|
|
104
|
+
const baseUrl = typeof portOrTarget === "number"
|
|
105
|
+
? `http://127.0.0.1:${portOrTarget}`
|
|
106
|
+
: new URL(portOrTarget.baseUrl).origin;
|
|
107
|
+
|
|
88
108
|
// ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
|
|
89
109
|
// #5): the cache prewrite must not depend on UA sniffing.
|
|
90
|
-
const res = await fetch(
|
|
110
|
+
const res = await (options.fetchImpl ?? fetch)(`${baseUrl}/v1/models?limit=1000&ids=cli`, {
|
|
91
111
|
headers,
|
|
92
112
|
signal: AbortSignal.timeout(options.timeoutMs ?? 3_000),
|
|
93
113
|
});
|
|
@@ -100,7 +120,7 @@ export async function refreshGatewayModelCacheFromProxy(
|
|
|
100
120
|
id: m.id as string,
|
|
101
121
|
display_name: typeof m.display_name === "string" ? m.display_name : undefined,
|
|
102
122
|
}));
|
|
103
|
-
return writeGatewayModelCache(
|
|
123
|
+
return writeGatewayModelCache(baseUrl, models, options.configDir);
|
|
104
124
|
} catch {
|
|
105
125
|
return null;
|
|
106
126
|
}
|
package/src/claude/model-info.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog";
|
|
19
19
|
import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
|
|
20
|
+
import { cursorFastIdFor } from "../adapters/cursor/catalog";
|
|
20
21
|
import { desktop3pAlias } from "./desktop-3p";
|
|
21
22
|
import { AUTO_CONTEXT_OFF, type AutoContextMode } from "./context-windows";
|
|
22
23
|
|
|
@@ -109,6 +110,7 @@ export function buildAnthropicModelInfos(
|
|
|
109
110
|
idStyle: AnthropicIdStyle = "desktop3p",
|
|
110
111
|
aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
|
|
111
112
|
nativeContextCap?: NativeContextLimitsInput,
|
|
113
|
+
fastMode?: boolean,
|
|
112
114
|
): AnthropicModelInfo[] {
|
|
113
115
|
const out: AnthropicModelInfo[] = [];
|
|
114
116
|
const seen = new Set<string>();
|
|
@@ -151,7 +153,16 @@ export function buildAnthropicModelInfos(
|
|
|
151
153
|
push1mVariant(info, nativeWindow, nativeMaxInput);
|
|
152
154
|
}
|
|
153
155
|
for (const m of routedModels) {
|
|
154
|
-
|
|
156
|
+
// Global Fast has no toggle on this surface, so the fast identity is what gets listed —
|
|
157
|
+
// a client here can only pick a listed id. Limited to the readable CLI style: Desktop 3P
|
|
158
|
+
// ids are hashed from the model name, so rewriting them would strand a saved selection.
|
|
159
|
+
const fastModelId = fastMode === true && m.provider === "cursor" && idStyle === "readable"
|
|
160
|
+
? cursorFastIdFor(m.id)
|
|
161
|
+
: undefined;
|
|
162
|
+
const listedModelId = fastModelId ?? m.id;
|
|
163
|
+
const id = idStyle === "readable"
|
|
164
|
+
? claudeCodeAlias(m.provider, listedModelId)
|
|
165
|
+
: aliasForRoute(m.provider, m.id);
|
|
155
166
|
if (seen.has(id)) continue;
|
|
156
167
|
seen.add(id);
|
|
157
168
|
const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
|
|
@@ -164,7 +175,7 @@ export function buildAnthropicModelInfos(
|
|
|
164
175
|
? Math.min(m.maxInputTokens, m.contextWindow)
|
|
165
176
|
: m.maxInputTokens)
|
|
166
177
|
: undefined;
|
|
167
|
-
const info = modelInfo(id, `${
|
|
178
|
+
const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow);
|
|
168
179
|
out.push(info);
|
|
169
180
|
// Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude
|
|
170
181
|
// routes — only a genuine >=1M window earns the variant row there.
|
package/src/cli/access.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
const USAGE = `Usage:
|
|
13
13
|
ocx access key [list] [--json]
|
|
14
14
|
ocx access key create [name] [--json]
|
|
15
|
+
ocx access key rotate <id> [--json]
|
|
16
|
+
ocx access key rotate commit <id> <rotation-id> [--json]
|
|
17
|
+
ocx access key rotate abort <id> <rotation-id> [--json]
|
|
15
18
|
ocx access key remove <id> --yes [--json]
|
|
16
19
|
ocx access endpoints [--json]
|
|
17
20
|
ocx access models [--json]
|
|
@@ -87,6 +90,33 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
87
90
|
]);
|
|
88
91
|
return;
|
|
89
92
|
}
|
|
93
|
+
if (action === "rotate") {
|
|
94
|
+
const operation = args[0] === "commit" || args[0] === "abort" ? args.shift()! : "start";
|
|
95
|
+
const id = args.shift();
|
|
96
|
+
if (!id) throw new CliUsageError("key id is required", USAGE);
|
|
97
|
+
if (operation === "start") {
|
|
98
|
+
rejectArgs(args, USAGE);
|
|
99
|
+
const result = await runtimeRequest<Record<string, unknown>>("/api/keys/rotate", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: JSON.stringify({ id }),
|
|
102
|
+
}, deps);
|
|
103
|
+
printData(result, wantsJson, [
|
|
104
|
+
`Started rotation for API key ${id}.`,
|
|
105
|
+
`New key (shown once): ${String(result.key ?? "")}`,
|
|
106
|
+
`After the client accepts it, commit with rotation id ${String(result.rotationId ?? "")}.`,
|
|
107
|
+
]);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const rotationId = args.shift();
|
|
111
|
+
if (!rotationId) throw new CliUsageError("rotation id is required", USAGE);
|
|
112
|
+
rejectArgs(args, USAGE);
|
|
113
|
+
const result = await runtimeRequest(operation === "commit" ? "/api/keys/rotate/commit" : "/api/keys/rotate", {
|
|
114
|
+
method: operation === "commit" ? "POST" : "DELETE",
|
|
115
|
+
body: JSON.stringify({ id, rotationId }),
|
|
116
|
+
}, deps);
|
|
117
|
+
printData(result, wantsJson, [`${operation === "commit" ? "Committed" : "Aborted"} rotation for API key ${id}.`]);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
90
120
|
if (action === "remove" || action === "delete") {
|
|
91
121
|
const id = args.shift();
|
|
92
122
|
const yes = takeFlag(args, "--yes");
|
|
@@ -347,9 +347,12 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
|
|
|
347
347
|
const action = args.shift();
|
|
348
348
|
if (!name || !action) return usage();
|
|
349
349
|
const classified = configAndType(deps, name);
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
// Anthropic keeps its threshold on its own pool contract; generic OAuth providers (#695)
|
|
351
|
+
// and the Codex pool are accepted here.
|
|
352
|
+
if ("error" in classified || classified.type === "api-key" || name === "anthropic") {
|
|
353
|
+
return usage("Error: auto-switch only applies to the openai Codex account pool or a generic OAuth provider pool");
|
|
352
354
|
}
|
|
355
|
+
const genericPool = classified.type === "oauth";
|
|
353
356
|
let threshold: number | undefined;
|
|
354
357
|
if (action === "on" && args.length === 0) threshold = 80;
|
|
355
358
|
else if (action === "off" && args.length === 0) threshold = 0;
|
|
@@ -361,14 +364,19 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
|
|
|
361
364
|
const baseUrl = await resolveBaseUrl(deps);
|
|
362
365
|
if (!baseUrl) return proxyUnreachable();
|
|
363
366
|
if (action === "status") {
|
|
364
|
-
const response = await apiJson(
|
|
367
|
+
const response = await apiJson(
|
|
368
|
+
deps, baseUrl, "GET",
|
|
369
|
+
genericPool ? `/api/oauth/accounts/pool?provider=${encodeURIComponent(name)}` : "/api/codex-auth/active",
|
|
370
|
+
);
|
|
365
371
|
if (response.status === 0) return proxyUnreachable(response.transportError);
|
|
366
|
-
if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
|
|
372
|
+
if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) {
|
|
367
373
|
return apiError(response.json, "failed to read auto-switch status", response.status);
|
|
368
374
|
}
|
|
369
|
-
threshold = response.json.autoSwitchThreshold;
|
|
375
|
+
threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0;
|
|
370
376
|
} else {
|
|
371
|
-
const response =
|
|
377
|
+
const response = genericPool
|
|
378
|
+
? await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pool", { provider: name, autoSwitchThreshold: threshold })
|
|
379
|
+
: await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
|
|
372
380
|
if (response.status === 0) return proxyUnreachable(response.transportError);
|
|
373
381
|
if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status);
|
|
374
382
|
}
|
|
@@ -846,16 +854,17 @@ function anthropicPoolTransport(provider: string): PoolTransport {
|
|
|
846
854
|
}
|
|
847
855
|
|
|
848
856
|
/**
|
|
849
|
-
*
|
|
850
|
-
*
|
|
857
|
+
* Codex and Anthropic keep their own pool transports; every other OAuth provider speaks the
|
|
858
|
+
* generic pool-settings contract on the same `/api/oauth/accounts/pool` route (#695).
|
|
859
|
+
* API-key providers have no pool and are refused here without a round-trip.
|
|
851
860
|
*/
|
|
852
861
|
function poolTransportFor(
|
|
853
862
|
classified: { type: "codex" | "oauth" | "api-key" },
|
|
854
863
|
name: string,
|
|
855
864
|
): PoolTransport | string {
|
|
856
865
|
if (classified.type === "codex") return CODEX_POOL_TRANSPORT;
|
|
857
|
-
if (classified.type === "oauth"
|
|
858
|
-
return `pool settings apply to
|
|
866
|
+
if (classified.type === "oauth") return anthropicPoolTransport(name);
|
|
867
|
+
return `pool settings apply to OAuth account pools, not the API-key provider "${name}"`;
|
|
859
868
|
}
|
|
860
869
|
|
|
861
870
|
/**
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -107,6 +107,29 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
107
107
|
json: "envelope",
|
|
108
108
|
details: ["Reads /healthz plus local config; drives no management API route."],
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
command: ["connect", "rotate"],
|
|
112
|
+
summary: "Rotate the connected client's data key against the hub, with commit and abort.",
|
|
113
|
+
// One command drives all three: start returns the new secret once, commit promotes it,
|
|
114
|
+
// and abort unwinds a rotation that could not be confirmed. They are not separate verbs
|
|
115
|
+
// because a half-rotation is not a state an operator should be able to leave behind.
|
|
116
|
+
routes: [
|
|
117
|
+
{ method: "POST", path: "/api/keys/rotate" },
|
|
118
|
+
{ method: "POST", path: "/api/keys/rotate/commit" },
|
|
119
|
+
{ method: "DELETE", path: "/api/keys/rotate" },
|
|
120
|
+
],
|
|
121
|
+
flags: [
|
|
122
|
+
{ name: "--pairing-code-stdin", value: "boolean", summary: "Read a one-time pairing code from stdin as the rotation authority." },
|
|
123
|
+
{ name: "--admin-token-stdin", value: "boolean", summary: "Read the hub admin token from stdin as the rotation authority." },
|
|
124
|
+
{ name: "--json", value: "boolean", summary: "Emit the rotation result as JSON." },
|
|
125
|
+
],
|
|
126
|
+
mutates: true,
|
|
127
|
+
json: "payload",
|
|
128
|
+
details: [
|
|
129
|
+
"Requires transient authority on stdin; the credential is never persisted or echoed.",
|
|
130
|
+
"A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live.",
|
|
131
|
+
],
|
|
132
|
+
},
|
|
110
133
|
{
|
|
111
134
|
command: ["capabilities"],
|
|
112
135
|
summary: "List the declared CLI capabilities and the management routes they drive.",
|
|
@@ -130,6 +153,21 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
130
153
|
json: "envelope",
|
|
131
154
|
details: ["Reads local config; drives no management API route."],
|
|
132
155
|
},
|
|
156
|
+
{
|
|
157
|
+
command: ["provider", "keychain"],
|
|
158
|
+
summary: "Move a provider's API key into the OS keychain, restore it, or report where it lives.",
|
|
159
|
+
routes: [
|
|
160
|
+
{ method: "GET", path: "/api/providers/keychain" },
|
|
161
|
+
{ method: "POST", path: "/api/providers/keychain" },
|
|
162
|
+
],
|
|
163
|
+
flags: [{ name: "--json", value: "boolean", summary: "Emit the keychain status or result as JSON." }],
|
|
164
|
+
mutates: true,
|
|
165
|
+
json: "payload",
|
|
166
|
+
details: [
|
|
167
|
+
"`store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched.",
|
|
168
|
+
"Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
|
|
169
|
+
],
|
|
170
|
+
},
|
|
133
171
|
{
|
|
134
172
|
command: ["account", "list"],
|
|
135
173
|
summary: "Codex OAuth accounts with pool priority and pause state.",
|
|
@@ -460,13 +498,14 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
460
498
|
},
|
|
461
499
|
{
|
|
462
500
|
command: ["integration", "native"],
|
|
463
|
-
summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations.",
|
|
501
|
+
summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen).",
|
|
464
502
|
routes: [
|
|
465
503
|
{ method: "GET", path: "/api/native-integrations" },
|
|
466
504
|
{ method: "PUT", path: "/api/native-integrations/claude" },
|
|
467
505
|
{ method: "PUT", path: "/api/native-integrations/claude-desktop" },
|
|
468
506
|
{ method: "PUT", path: "/api/native-integrations/codex" },
|
|
469
507
|
{ method: "PUT", path: "/api/native-integrations/grok" },
|
|
508
|
+
{ method: "GET", path: "/api/native-integrations/cursor" },
|
|
470
509
|
],
|
|
471
510
|
flags: [{ name: "--json", value: "boolean", summary: "Emit the client rows or toggle result as JSON." }],
|
|
472
511
|
mutates: true,
|
|
@@ -54,6 +54,9 @@ export async function syncClaudeAgentDefsAtProxyStartup(
|
|
|
54
54
|
const warn = deps.warn ?? (message => console.warn(message));
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
|
+
// Hub role: never rewrite this host's ~/.claude roster on startup (same rule as
|
|
58
|
+
// shouldSyncCodexOnStart / shouldSyncGrokOnStart — the hub serves other machines).
|
|
59
|
+
if (config.runtimeRole === "hub") return null;
|
|
57
60
|
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
|
|
58
61
|
return inject(config, {});
|
|
59
62
|
}
|
package/src/cli/claude.ts
CHANGED
|
@@ -21,11 +21,22 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
|
|
|
21
21
|
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
22
22
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
23
23
|
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
|
|
24
|
+
import { readClientConnectionState } from "../client/state";
|
|
25
|
+
import { readServiceApiTokenState } from "../lib/service-secrets";
|
|
26
|
+
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { aliasForNative, aliasForRoute } from "../claude/alias";
|
|
29
|
+
import { desktop3pAlias } from "../claude/desktop-3p";
|
|
24
30
|
|
|
25
31
|
export interface ClaudeLaunchEnv {
|
|
26
32
|
[key: string]: string | undefined;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
export interface ClaudeRoutingTarget {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
admissionToken: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
|
|
31
42
|
* launch base so detection and the spawned process can never disagree (audit R3-3).
|
|
@@ -61,6 +72,20 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
|
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
function targetsClaudeRoutingTarget(value: string | undefined, target: ClaudeRoutingTarget): boolean {
|
|
76
|
+
if (!value) return false;
|
|
77
|
+
try {
|
|
78
|
+
const actual = new URL(value);
|
|
79
|
+
const expected = new URL(target.baseUrl);
|
|
80
|
+
return actual.origin === expected.origin
|
|
81
|
+
&& (actual.pathname === "/" || actual.pathname === "")
|
|
82
|
+
&& !actual.username
|
|
83
|
+
&& !actual.password;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
/**
|
|
65
90
|
* Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
|
|
66
91
|
* token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
|
|
@@ -70,11 +95,14 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
|
|
|
70
95
|
*/
|
|
71
96
|
export function buildClaudeEnv(
|
|
72
97
|
config: OcxConfig,
|
|
73
|
-
|
|
98
|
+
portOrTarget: number | ClaudeRoutingTarget,
|
|
74
99
|
base: ClaudeLaunchEnv,
|
|
75
100
|
contextWindows: Record<string, number> = {},
|
|
76
101
|
deps: ClaudeEnvDeps = {},
|
|
77
102
|
): ClaudeLaunchEnv {
|
|
103
|
+
const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
|
|
104
|
+
const port = typeof portOrTarget === "number" ? portOrTarget : null;
|
|
105
|
+
const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`;
|
|
78
106
|
const env: ClaudeLaunchEnv = { ...base };
|
|
79
107
|
// Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
|
|
80
108
|
// or writes the token slot. setDefault below preserves any non-empty value, so a
|
|
@@ -120,9 +148,9 @@ export function buildClaudeEnv(
|
|
|
120
148
|
if (deps.allowRootSkipPermissions === true) {
|
|
121
149
|
setDefault("IS_SANDBOX", "1");
|
|
122
150
|
}
|
|
123
|
-
setDefault("ANTHROPIC_BASE_URL",
|
|
151
|
+
setDefault("ANTHROPIC_BASE_URL", managedBaseUrl);
|
|
124
152
|
const existingBaseUrl = env.ANTHROPIC_BASE_URL;
|
|
125
|
-
if (existingBaseUrl) {
|
|
153
|
+
if (existingBaseUrl && port !== null) {
|
|
126
154
|
try {
|
|
127
155
|
const parsed = new URL(existingBaseUrl);
|
|
128
156
|
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
|
|
@@ -151,19 +179,23 @@ export function buildClaudeEnv(
|
|
|
151
179
|
}
|
|
152
180
|
// Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
|
|
153
181
|
// setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
|
|
154
|
-
// the user's Claude login.
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
const ownTokens = ownAdmissionTokens(config);
|
|
158
|
-
const targetsLocalProxy =
|
|
182
|
+
// the user's Claude login. Resolve the mode before adding any proxy-owned credential:
|
|
183
|
+
// subscription launches must keep their OAuth, while proxy launches may use the
|
|
184
|
+
// admission key or dummy marker (see server/claude-messages.ts).
|
|
185
|
+
const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config);
|
|
186
|
+
const targetsLocalProxy = explicitTarget
|
|
187
|
+
? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
|
|
188
|
+
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!);
|
|
189
|
+
const isOwnAdmissionToken = (value: string): boolean =>
|
|
190
|
+
ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
|
|
159
191
|
const inheritedApiKey = env.ANTHROPIC_API_KEY;
|
|
160
|
-
if (typeof inheritedApiKey === "string" &&
|
|
192
|
+
if (typeof inheritedApiKey === "string" && isOwnAdmissionToken(inheritedApiKey)) {
|
|
161
193
|
delete env.ANTHROPIC_API_KEY;
|
|
162
194
|
}
|
|
163
195
|
const hasUserApiKey = Boolean(env.ANTHROPIC_API_KEY?.trim());
|
|
164
196
|
const inheritedAuthToken = env.ANTHROPIC_AUTH_TOKEN;
|
|
165
197
|
const inheritedTokenIsOurs = typeof inheritedAuthToken === "string"
|
|
166
|
-
&&
|
|
198
|
+
&& isOwnAdmissionToken(inheritedAuthToken);
|
|
167
199
|
// system-env may have injected the proxy's admission key into the parent. A
|
|
168
200
|
// proof-bound external BASE_URL is still user-owned, so never let our inherited
|
|
169
201
|
// key follow it. A user API key also wins on a local launch; remove only the token
|
|
@@ -172,24 +204,30 @@ export function buildClaudeEnv(
|
|
|
172
204
|
if (inheritedTokenIsOurs && (!targetsLocalProxy || hasUserApiKey)) {
|
|
173
205
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
174
206
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Detection reads the SANITIZED launch env — the exact object spawned below — so the
|
|
179
|
-
// resolver and the spawned process cannot disagree. It deliberately does NOT read the
|
|
180
|
-
// raw base: the provenance strip above already removed dotenv-only credentials, and
|
|
181
|
-
// letting a value the child never receives decide the marker left an auto-mode user
|
|
182
|
-
// with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
|
|
183
|
-
// are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
|
|
184
|
-
// test fake cannot break that. `ownTokens` is bound last for the same reason: it is
|
|
185
|
-
// config-derived, and a fake that replaced it could make our own admission key look
|
|
186
|
-
// like user auth.
|
|
207
|
+
// Detection reads the sanitized launch env before proxy-owned credentials are added.
|
|
208
|
+
// The provenance strip above removed dotenv-only credentials, and ownTokens keeps a
|
|
209
|
+
// configured admission key from being mistaken for user auth (#701 audit round 2).
|
|
187
210
|
const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
|
|
188
211
|
...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
|
|
189
212
|
...(deps.authDetect ?? {}),
|
|
190
213
|
env: () => env as NodeJS.ProcessEnv,
|
|
191
214
|
ownTokens,
|
|
192
215
|
}));
|
|
216
|
+
// An explicit connected target is not a subscription launch. The caller named a hub and
|
|
217
|
+
// handed us the client admission token for it, so auth-mode detection - which reads the
|
|
218
|
+
// local environment - has no bearing on whether that token belongs in the child env.
|
|
219
|
+
// Without this, a machine whose environment reads as subscription strips the very
|
|
220
|
+
// credential the connected launch was constructed with (#3148 carry).
|
|
221
|
+
if (resolved.markerMode === "subscription" && !explicitTarget) {
|
|
222
|
+
// A prior system-env snapshot may have left our admission key in the inherited
|
|
223
|
+
// environment. It belongs to the proxy data plane, not Claude subscription OAuth.
|
|
224
|
+
const token = env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
225
|
+
if (token && (token === PROXY_MARKER || isProxyAdmissionSecret(token, config))) {
|
|
226
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
227
|
+
}
|
|
228
|
+
} else if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) {
|
|
229
|
+
setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]);
|
|
230
|
+
}
|
|
193
231
|
if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
|
|
194
232
|
env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
|
|
195
233
|
}
|
|
@@ -199,7 +237,7 @@ export function buildClaudeEnv(
|
|
|
199
237
|
&& typeof finalAuthToken === "string"
|
|
200
238
|
&& (
|
|
201
239
|
finalAuthToken.trim() === PROXY_MARKER
|
|
202
|
-
||
|
|
240
|
+
|| isOwnAdmissionToken(finalAuthToken)
|
|
203
241
|
);
|
|
204
242
|
if (resolved.origin === "auto-unknown") {
|
|
205
243
|
console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다.");
|
|
@@ -282,6 +320,40 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number,
|
|
|
282
320
|
}
|
|
283
321
|
}
|
|
284
322
|
|
|
323
|
+
export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record<string, number> {
|
|
324
|
+
try {
|
|
325
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown };
|
|
326
|
+
if (!Array.isArray(parsed.models)) return {};
|
|
327
|
+
const out: Record<string, number> = {};
|
|
328
|
+
const put = (key: string, value: number) => { if (out[key] === undefined) out[key] = value; };
|
|
329
|
+
for (const row of parsed.models) {
|
|
330
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
331
|
+
const entry = row as Record<string, unknown>;
|
|
332
|
+
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
333
|
+
const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0
|
|
334
|
+
? entry.context_window
|
|
335
|
+
: undefined;
|
|
336
|
+
if (!slug || contextWindow === undefined) continue;
|
|
337
|
+
put(slug, contextWindow);
|
|
338
|
+
const slash = slug.indexOf("/");
|
|
339
|
+
if (slash > 0 && slash < slug.length - 1) {
|
|
340
|
+
const provider = slug.slice(0, slash);
|
|
341
|
+
const id = slug.slice(slash + 1);
|
|
342
|
+
const routeAlias = aliasForRoute(provider, id);
|
|
343
|
+
if (routeAlias) put(routeAlias, contextWindow);
|
|
344
|
+
put(desktop3pAlias(provider, id), contextWindow);
|
|
345
|
+
} else {
|
|
346
|
+
const nativeAlias = aliasForNative(slug);
|
|
347
|
+
if (nativeAlias) put(nativeAlias, contextWindow);
|
|
348
|
+
put(desktop3pAlias("native", slug), contextWindow);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
} catch {
|
|
353
|
+
return {};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
285
357
|
async function ensureProxyForClaude(): Promise<number | null> {
|
|
286
358
|
const live = await findLiveProxy();
|
|
287
359
|
if (live) return live.port;
|
|
@@ -340,21 +412,45 @@ export async function cmdClaude(args: string[]): Promise<number> {
|
|
|
340
412
|
console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config).");
|
|
341
413
|
return 1;
|
|
342
414
|
}
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
345
|
-
console.error(
|
|
415
|
+
const clientState = readClientConnectionState();
|
|
416
|
+
if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
|
|
417
|
+
console.error(`Client state is ${clientState.kind}: ${clientState.reason}`);
|
|
346
418
|
return 1;
|
|
347
419
|
}
|
|
348
|
-
|
|
420
|
+
let route: number | ClaudeRoutingTarget;
|
|
421
|
+
let contextWindows: Record<string, number>;
|
|
422
|
+
if (clientState.kind === "connected") {
|
|
423
|
+
if (!clientState.value.selectedClients.includes("claude")) {
|
|
424
|
+
console.error("Claude is not selected for this remote hub connection.");
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
const token = readServiceApiTokenState();
|
|
428
|
+
if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) {
|
|
429
|
+
console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed.");
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token };
|
|
433
|
+
contextWindows = readConnectedClaudeContextWindows();
|
|
434
|
+
} else {
|
|
435
|
+
const port = await ensureProxyForClaude();
|
|
436
|
+
if (!port) {
|
|
437
|
+
console.error("❌ Proxy did not become healthy after starting.");
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
route = port;
|
|
441
|
+
contextWindows = await fetchClaudeContextWindows(config, port);
|
|
442
|
+
}
|
|
349
443
|
const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args);
|
|
350
|
-
const env = buildClaudeEnv(config,
|
|
444
|
+
const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions });
|
|
351
445
|
if (allowRootSkipPermissions) {
|
|
352
446
|
console.error(rootSkipPermissionsNotice(env));
|
|
353
447
|
}
|
|
354
448
|
// Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
|
|
355
449
|
// never refreshes it, so the picker would keep showing yesterday's aliases.
|
|
356
450
|
try {
|
|
357
|
-
const cachePath =
|
|
451
|
+
const cachePath = typeof route === "number"
|
|
452
|
+
? await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config })
|
|
453
|
+
: await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config });
|
|
358
454
|
if (cachePath === null) {
|
|
359
455
|
console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
|
|
360
456
|
}
|
|
@@ -363,14 +459,16 @@ export async function cmdClaude(args: string[]): Promise<number> {
|
|
|
363
459
|
console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
|
|
364
460
|
}
|
|
365
461
|
// Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
462
|
+
if (typeof route === "number") {
|
|
463
|
+
try {
|
|
464
|
+
const written = injectClaudeAgentDefs(config, contextWindows);
|
|
465
|
+
if (written === null) {
|
|
466
|
+
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
|
|
467
|
+
}
|
|
468
|
+
} catch (error) {
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
|
|
370
471
|
}
|
|
371
|
-
} catch (error) {
|
|
372
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
373
|
-
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
|
|
374
472
|
}
|
|
375
473
|
return await new Promise<number>(resolve => {
|
|
376
474
|
const inv = commandInvocation("claude", args);
|