@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude auth presence detection.
|
|
3
|
+
*
|
|
4
|
+
* THREE values, not two. `unknown` means a source could not be READ (denied keychain,
|
|
5
|
+
* permission error, corrupt JSON) — treating that as `absent` would silently flip a
|
|
6
|
+
* subscriber into proxy mode, which is the failure this whole unit exists to prevent
|
|
7
|
+
* (devlog/_plan/260726_claude_auth_auto/001 F1). The resolver maps unknown to the
|
|
8
|
+
* historical default (subscription), never to proxy.
|
|
9
|
+
*
|
|
10
|
+
* IO is injectable so every source is testable without touching the real home
|
|
11
|
+
* directory, keychain, or environment.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
export type AuthPresence = "present" | "absent" | "unknown";
|
|
19
|
+
|
|
20
|
+
export type AuthSourceId =
|
|
21
|
+
| "claude-json-oauth" // S1: <config-dir>/../.claude.json oauthAccount
|
|
22
|
+
| "claude-credentials-file" // S2: <config-dir>/.credentials.json
|
|
23
|
+
| "macos-keychain" // S3: security find-generic-password (metadata only)
|
|
24
|
+
| "exported-env"; // S5: ANTHROPIC_API_KEY / a USER's ANTHROPIC_AUTH_TOKEN
|
|
25
|
+
|
|
26
|
+
// NOTE: there is deliberately no "opencodex anthropic OAuth" source. That is a
|
|
27
|
+
// PROVIDER credential in opencodex's own store which the Claude CLI never consumes,
|
|
28
|
+
// so it is not evidence the client can authenticate natively (audit 002 §5).
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one opencodex-owned dummy token. Exported so the CLI, the system-env writer and
|
|
32
|
+
* the tests all compare against the same literal — a second copy is how the marker
|
|
33
|
+
* feedback loop (002 §1) sneaks back in.
|
|
34
|
+
*/
|
|
35
|
+
export const PROXY_MARKER = "opencodex-proxy";
|
|
36
|
+
|
|
37
|
+
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
38
|
+
/** `security` exit code for "the item does not exist" — a real absent, not a failure. */
|
|
39
|
+
const KEYCHAIN_ITEM_NOT_FOUND = 44;
|
|
40
|
+
const KEYCHAIN_TIMEOUT_MS = 1_500;
|
|
41
|
+
|
|
42
|
+
export interface AuthSourceResult {
|
|
43
|
+
source: AuthSourceId;
|
|
44
|
+
presence: AuthPresence;
|
|
45
|
+
/** One short, credential-free line. Never contains token material. */
|
|
46
|
+
detail?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthDetectDeps {
|
|
50
|
+
/** Parsed ~/.claude.json; undefined when missing; throws on corrupt/unreadable. */
|
|
51
|
+
readClaudeJson(): Record<string, unknown> | undefined;
|
|
52
|
+
/** Whether <config-dir>/.credentials.json exists; throws on read error. */
|
|
53
|
+
credentialsFileExists(): boolean;
|
|
54
|
+
/** present/absent/unknown for the macOS keychain probe; absent on non-Darwin. */
|
|
55
|
+
keychainProbe(): AuthPresence;
|
|
56
|
+
/**
|
|
57
|
+
* The environment to inspect for S5. Callers pass the SAME base env the launch will
|
|
58
|
+
* use, so detection and the spawned process can never disagree. Injection is typed
|
|
59
|
+
* to exclude this key (see `detectClaudeAuth` callers) so a test fake cannot
|
|
60
|
+
* silently replace the binding.
|
|
61
|
+
*/
|
|
62
|
+
env(): NodeJS.ProcessEnv;
|
|
63
|
+
/**
|
|
64
|
+
* Token values opencodex itself put into the environment. `system-env.ts` exports the
|
|
65
|
+
* configured admission key as `ANTHROPIC_AUTH_TOKEN`, so without this the detector
|
|
66
|
+
* reads OUR OWN output back as proof the user can authenticate natively — the same
|
|
67
|
+
* feedback loop the `PROXY_MARKER` guard closes, one variable over.
|
|
68
|
+
*/
|
|
69
|
+
ownTokens?: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AuthDetectResult {
|
|
73
|
+
/** present if ANY source is present; unknown if none present but ANY unknown. */
|
|
74
|
+
presence: AuthPresence;
|
|
75
|
+
/** The source that proved presence, when any — feeds the GUI reason badge. */
|
|
76
|
+
foundBy?: AuthSourceId;
|
|
77
|
+
sources: AuthSourceResult[];
|
|
78
|
+
/**
|
|
79
|
+
* True when the inspected env carries OUR OWN dummy marker. It must never count as
|
|
80
|
+
* user auth, and a subscription resolution strips it before launch (002 §1).
|
|
81
|
+
*/
|
|
82
|
+
staleProxyMarker: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Claude Code config dir: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
|
|
86
|
+
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
87
|
+
const explicit = env.CLAUDE_CONFIG_DIR?.trim();
|
|
88
|
+
return explicit ? explicit : join(homeDir(env), ".claude");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `os.homedir()` reads the OS user database and ignores a reassigned `HOME`, which
|
|
93
|
+
* makes an isolated-profile probe silently read the real user's files. Prefer the
|
|
94
|
+
* environment the caller handed us; fall back to the OS value.
|
|
95
|
+
*/
|
|
96
|
+
function homeDir(env: NodeJS.ProcessEnv): string {
|
|
97
|
+
const fromEnv = env.HOME?.trim() || env.USERPROFILE?.trim();
|
|
98
|
+
return fromEnv ? fromEnv : homedir();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function detectClaudeJson(deps: AuthDetectDeps): AuthSourceResult {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = deps.readClaudeJson();
|
|
104
|
+
if (parsed === undefined) return { source: "claude-json-oauth", presence: "absent" };
|
|
105
|
+
const account = parsed.oauthAccount;
|
|
106
|
+
if (account && typeof account === "object" && !Array.isArray(account)) {
|
|
107
|
+
const email = (account as Record<string, unknown>).emailAddress;
|
|
108
|
+
if (typeof email === "string" && email.trim().length > 0) {
|
|
109
|
+
// Never echo the address itself — the detail line is UI copy, not a credential.
|
|
110
|
+
return { source: "claude-json-oauth", presence: "present", detail: "oauthAccount" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { source: "claude-json-oauth", presence: "absent" };
|
|
114
|
+
} catch {
|
|
115
|
+
// Corrupt or unreadable is NOT absent (F1).
|
|
116
|
+
return { source: "claude-json-oauth", presence: "unknown", detail: "unreadable" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function detectCredentialsFile(deps: AuthDetectDeps): AuthSourceResult {
|
|
121
|
+
try {
|
|
122
|
+
return {
|
|
123
|
+
source: "claude-credentials-file",
|
|
124
|
+
presence: deps.credentialsFileExists() ? "present" : "absent",
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return { source: "claude-credentials-file", presence: "unknown", detail: "unreadable" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectKeychain(deps: AuthDetectDeps): AuthSourceResult {
|
|
132
|
+
try {
|
|
133
|
+
return { source: "macos-keychain", presence: deps.keychainProbe() };
|
|
134
|
+
} catch {
|
|
135
|
+
return { source: "macos-keychain", presence: "unknown", detail: "probe failed" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function detectExportedEnv(deps: AuthDetectDeps): AuthSourceResult {
|
|
140
|
+
try {
|
|
141
|
+
const env = deps.env();
|
|
142
|
+
const isOwn = (value: string): boolean =>
|
|
143
|
+
value === PROXY_MARKER || (deps.ownTokens ?? []).includes(value);
|
|
144
|
+
const apiKey = env.ANTHROPIC_API_KEY?.trim();
|
|
145
|
+
if (apiKey && !isOwn(apiKey)) {
|
|
146
|
+
return { source: "exported-env", presence: "present", detail: "ANTHROPIC_API_KEY" };
|
|
147
|
+
}
|
|
148
|
+
const token = env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
149
|
+
// Our own dummy is opencodex state, never user auth: counting it would make a
|
|
150
|
+
// proxy-mode launch look authenticated on the NEXT launch (002 §1). The configured
|
|
151
|
+
// admission key is opencodex state for exactly the same reason — the system-env
|
|
152
|
+
// writer exports it into this very variable.
|
|
153
|
+
if (token && !isOwn(token)) {
|
|
154
|
+
return { source: "exported-env", presence: "present", detail: "ANTHROPIC_AUTH_TOKEN" };
|
|
155
|
+
}
|
|
156
|
+
return { source: "exported-env", presence: "absent" };
|
|
157
|
+
} catch {
|
|
158
|
+
return { source: "exported-env", presence: "unknown" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function detectClaudeAuth(deps: AuthDetectDeps): AuthDetectResult {
|
|
163
|
+
const sources = [
|
|
164
|
+
detectClaudeJson(deps),
|
|
165
|
+
detectCredentialsFile(deps),
|
|
166
|
+
detectKeychain(deps),
|
|
167
|
+
detectExportedEnv(deps),
|
|
168
|
+
];
|
|
169
|
+
let staleProxyMarker = false;
|
|
170
|
+
try {
|
|
171
|
+
staleProxyMarker = deps.env().ANTHROPIC_AUTH_TOKEN?.trim() === PROXY_MARKER;
|
|
172
|
+
} catch {
|
|
173
|
+
staleProxyMarker = false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const present = sources.find(source => source.presence === "present");
|
|
177
|
+
if (present) return { presence: "present", foundBy: present.source, sources, staleProxyMarker };
|
|
178
|
+
if (sources.some(source => source.presence === "unknown")) {
|
|
179
|
+
return { presence: "unknown", sources, staleProxyMarker };
|
|
180
|
+
}
|
|
181
|
+
return { presence: "absent", sources, staleProxyMarker };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Real IO. The keychain probe is METADATA ONLY: no `-g` and no `-w`, because those
|
|
186
|
+
* flags print the password itself. We only need the exit code.
|
|
187
|
+
*/
|
|
188
|
+
export function defaultAuthDetectDeps(
|
|
189
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
190
|
+
ownTokens: readonly string[] = [],
|
|
191
|
+
): AuthDetectDeps {
|
|
192
|
+
const configDir = claudeConfigDir(env);
|
|
193
|
+
return {
|
|
194
|
+
readClaudeJson() {
|
|
195
|
+
// Sibling of the config dir when CLAUDE_CONFIG_DIR is set, else ~/.claude.json.
|
|
196
|
+
const path = env.CLAUDE_CONFIG_DIR?.trim()
|
|
197
|
+
? join(configDir, "..", ".claude.json")
|
|
198
|
+
: join(homeDir(env), ".claude.json");
|
|
199
|
+
if (!existsSync(path)) return undefined;
|
|
200
|
+
// A parse failure propagates so the caller records `unknown`, not `absent`.
|
|
201
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
202
|
+
},
|
|
203
|
+
credentialsFileExists() {
|
|
204
|
+
return existsSync(join(configDir, ".credentials.json"));
|
|
205
|
+
},
|
|
206
|
+
keychainProbe() {
|
|
207
|
+
if (process.platform !== "darwin") return "absent";
|
|
208
|
+
const result = spawnSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE], {
|
|
209
|
+
timeout: KEYCHAIN_TIMEOUT_MS,
|
|
210
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
211
|
+
});
|
|
212
|
+
if (result.error || result.signal !== null) return "unknown";
|
|
213
|
+
if (result.status === 0) return "present";
|
|
214
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND) return "absent";
|
|
215
|
+
return "unknown";
|
|
216
|
+
},
|
|
217
|
+
env: () => env,
|
|
218
|
+
ownTokens,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The token values opencodex itself exports. Configured admission keys land in
|
|
224
|
+
* `ANTHROPIC_AUTH_TOKEN` (see `system-env.ts`), so detection must not read them back
|
|
225
|
+
* as user auth.
|
|
226
|
+
*/
|
|
227
|
+
export function ownAdmissionTokens(config: { apiKeys?: Array<{ key: string }> }): string[] {
|
|
228
|
+
return (config.apiKeys ?? []).map(entry => entry.key).filter(key => key.length > 0);
|
|
229
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time authMode migration (devlog/_plan/260726_claude_auth_auto/015).
|
|
3
|
+
*
|
|
4
|
+
* Before auto existed, choosing "Subscription" in the GUI DELETED the key. So on disk
|
|
5
|
+
* an explicit subscription choice and "never chose anything" are byte-identical, and
|
|
6
|
+
* reading both as auto would flip a deliberate subscriber into proxy mode the moment
|
|
7
|
+
* their credentials are undetectable. This migration preserves the old EFFECTIVE
|
|
8
|
+
* behaviour for configs that already had a claudeCode block, and leaves genuinely
|
|
9
|
+
* untouched configs on auto.
|
|
10
|
+
*/
|
|
11
|
+
import type { OcxConfig } from "../types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @returns true when the config changed and the caller should persist it.
|
|
15
|
+
*/
|
|
16
|
+
export function runClaudeAuthModeMigration(config: OcxConfig, now = new Date()): boolean {
|
|
17
|
+
const claudeCode = config.claudeCode;
|
|
18
|
+
// No block at all = a fresh or Claude-untouched install: auto is the right default,
|
|
19
|
+
// and writing a sentinel here would create the block for no reason.
|
|
20
|
+
if (!claudeCode) return false;
|
|
21
|
+
// Idempotent: the sentinel's presence is the "already migrated" fact. A user who
|
|
22
|
+
// later picks Auto deletes authMode but KEEPS the sentinel, so the next start must
|
|
23
|
+
// not resurrect subscription.
|
|
24
|
+
if (typeof claudeCode.authModeMigratedAt === "string" && claudeCode.authModeMigratedAt.length > 0) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (claudeCode.authMode === undefined) {
|
|
28
|
+
claudeCode.authMode = "subscription";
|
|
29
|
+
}
|
|
30
|
+
claudeCode.authModeMigratedAt = now.toISOString();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude auth-mode resolution.
|
|
3
|
+
*
|
|
4
|
+
* The resolver answers exactly ONE question: does the opencodex-owned dummy token
|
|
5
|
+
* (`ANTHROPIC_AUTH_TOKEN=opencodex-proxy`) get injected? That is narrower than "how
|
|
6
|
+
* will Claude authenticate" — native passthrough additionally needs an `sk-ant-`
|
|
7
|
+
* credential on the incoming request — so the field is `markerMode`, not
|
|
8
|
+
* `effectiveAuthMode` (devlog/_plan/260726_claude_auth_auto/002 R2-1).
|
|
9
|
+
*
|
|
10
|
+
* The admission-key axis is separate and untouched: when the proxy requires an
|
|
11
|
+
* admission key, `buildClaudeEnv` injects it regardless of mode.
|
|
12
|
+
*/
|
|
13
|
+
import type { OcxConfig } from "../types";
|
|
14
|
+
import type { AuthDetectResult, AuthSourceId } from "./auth-detect";
|
|
15
|
+
|
|
16
|
+
export type MarkerMode = "proxy" | "subscription";
|
|
17
|
+
|
|
18
|
+
export type AuthModeOrigin = "manual" | "auto-present" | "auto-absent" | "auto-unknown";
|
|
19
|
+
|
|
20
|
+
export interface ResolvedAuthMode {
|
|
21
|
+
/** Does the owned dummy marker get injected. NOT a claim about native auth. */
|
|
22
|
+
markerMode: MarkerMode;
|
|
23
|
+
origin: AuthModeOrigin;
|
|
24
|
+
/** The detector source that proved presence (origin auto-present only). */
|
|
25
|
+
foundBy?: AuthSourceId;
|
|
26
|
+
detection: AuthDetectResult;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads `authMode`; never writes it. An explicit "proxy"/"subscription" bypasses the
|
|
31
|
+
* detector forever, which is what makes a manual choice stick across auth changes.
|
|
32
|
+
*
|
|
33
|
+
* `unknown` resolves to subscription — the historical default — because flipping a
|
|
34
|
+
* subscriber into proxy mode on a failed read (denied keychain, unreadable file) is
|
|
35
|
+
* the worst outcome this feature can produce.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveClaudeAuthMode(config: OcxConfig, detection: AuthDetectResult): ResolvedAuthMode {
|
|
38
|
+
const authMode = config.claudeCode?.authMode;
|
|
39
|
+
if (authMode === "proxy") return { markerMode: "proxy", origin: "manual", detection };
|
|
40
|
+
if (authMode === "subscription") return { markerMode: "subscription", origin: "manual", detection };
|
|
41
|
+
|
|
42
|
+
switch (detection.presence) {
|
|
43
|
+
case "present":
|
|
44
|
+
return {
|
|
45
|
+
markerMode: "subscription",
|
|
46
|
+
origin: "auto-present",
|
|
47
|
+
...(detection.foundBy ? { foundBy: detection.foundBy } : {}),
|
|
48
|
+
detection,
|
|
49
|
+
};
|
|
50
|
+
case "absent":
|
|
51
|
+
return { markerMode: "proxy", origin: "auto-absent", detection };
|
|
52
|
+
default:
|
|
53
|
+
return { markerMode: "subscription", origin: "auto-unknown", detection };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The three-state intent as the API and GUI express it. */
|
|
58
|
+
export type AuthModeIntent = "auto" | "proxy" | "subscription";
|
|
59
|
+
|
|
60
|
+
export function authModeIntent(config: OcxConfig): AuthModeIntent {
|
|
61
|
+
return config.claudeCode?.authMode ?? "auto";
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path validation for the Desktop 3P model list.
|
|
3
|
+
*
|
|
4
|
+
* The request path has its own guards (anthropic-image-guard.ts); this is the
|
|
5
|
+
* config-output counterpart. A bad entry here ships inside a user-visible config file,
|
|
6
|
+
* so it fails LOUD at the write boundary rather than silently producing a config
|
|
7
|
+
* Desktop rejects or mislabels.
|
|
8
|
+
*/
|
|
9
|
+
import type { Desktop3pModelEntry } from "./desktop-3p";
|
|
10
|
+
|
|
11
|
+
const NAME_PATTERN = /^claude-[a-z0-9-]+$/;
|
|
12
|
+
const MAX_LABEL_CHARS = 80;
|
|
13
|
+
/** Bracket capability markers must already have been normalized away by displayModelId. */
|
|
14
|
+
const FORBIDDEN_LABEL_CHARS = /[[\]]/;
|
|
15
|
+
|
|
16
|
+
export function assertDesktop3pModelsValid(models: readonly Desktop3pModelEntry[]): void {
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
for (const model of models) {
|
|
19
|
+
if (!NAME_PATTERN.test(model.name)) {
|
|
20
|
+
throw new Error(`Desktop 3P model name is not a valid Claude-shaped id: ${model.name}`);
|
|
21
|
+
}
|
|
22
|
+
// Alias collisions are skipped upstream; if that skip ever regresses, a duplicate
|
|
23
|
+
// must fail here instead of writing a config Desktop cannot decode.
|
|
24
|
+
if (seen.has(model.name)) {
|
|
25
|
+
throw new Error(`Desktop 3P model name duplicated: ${model.name}`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(model.name);
|
|
28
|
+
if (FORBIDDEN_LABEL_CHARS.test(model.labelOverride)) {
|
|
29
|
+
throw new Error(`Desktop 3P label carries a raw capability marker: ${model.labelOverride}`);
|
|
30
|
+
}
|
|
31
|
+
if (model.labelOverride.length > MAX_LABEL_CHARS) {
|
|
32
|
+
throw new Error(`Desktop 3P label exceeds ${MAX_LABEL_CHARS} chars: ${model.labelOverride}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { atomicWriteFile } from "../config";
|
|
6
|
+
import type { OcxClaudeDesktopProfile } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
reconcileDesktopProfile,
|
|
9
|
+
renderDesktopProfile,
|
|
10
|
+
type DesktopProfileModel,
|
|
11
|
+
} from "./desktop-profile";
|
|
12
|
+
import { nativeOpenAiContextWindow } from "../codex/catalog";
|
|
13
|
+
import { assertDesktop3pModelsValid } from "./desktop-3p-guard";
|
|
5
14
|
|
|
6
15
|
export interface Desktop3pModelEntry {
|
|
7
16
|
name: string;
|
|
8
17
|
labelOverride: string;
|
|
9
|
-
anthropicFamilyTier: "opus";
|
|
18
|
+
anthropicFamilyTier: "opus" | "fable" | "sonnet" | "haiku";
|
|
10
19
|
isFamilyDefault?: boolean;
|
|
11
20
|
/**
|
|
12
21
|
* Desktop's documented 1M-context capability assertion. Set ONLY from an
|
|
13
22
|
* authoritative routed contextWindow >= 1M — never guessed (devlog 136 B5).
|
|
14
23
|
*/
|
|
15
24
|
supports1m?: true;
|
|
25
|
+
/** When true, Desktop selects the 1M variant by default (official schema, Luna research 260722). */
|
|
26
|
+
prefer1m?: true;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
/**
|
|
@@ -30,7 +41,13 @@ export interface Desktop3pRoutedModel {
|
|
|
30
41
|
contextWindow?: number;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 1M-context eligibility, shared with the Desktop DTO so the dashboard's 1M chip can
|
|
46
|
+
* never disagree with what the writer emits. The DTO imports this from here — keeping
|
|
47
|
+
* the constant in this module avoids a cycle, since shared.ts already reads
|
|
48
|
+
* claude/desktop-profile.
|
|
49
|
+
*/
|
|
50
|
+
export const DESKTOP_SUPPORTS_1M_THRESHOLD = 1_000_000;
|
|
34
51
|
|
|
35
52
|
/** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */
|
|
36
53
|
export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } {
|
|
@@ -59,6 +76,7 @@ interface Desktop3pMetadata {
|
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
let desktop3pRegistry = new Map<string, string>();
|
|
79
|
+
let desktop3pAliasesByRoute = new Map<string, string>();
|
|
62
80
|
|
|
63
81
|
/** Derive a stable letter-first, three-character base36 code from a route key. */
|
|
64
82
|
export function deriveDesktop3pCode(route: string): string {
|
|
@@ -89,11 +107,14 @@ export function legacyDesktop3pAlias(provider: string, modelId: string): string
|
|
|
89
107
|
|
|
90
108
|
function displayModelId(modelId: string): string {
|
|
91
109
|
return modelId
|
|
110
|
+
// Capability markers like [1m] are not name text: strip the brackets so the label
|
|
111
|
+
// reads "K3 1M", never "K3[1m]".
|
|
112
|
+
.replace(/\[([^\]]+)\]/g, "-$1")
|
|
92
113
|
.split(/[-_]+/)
|
|
93
114
|
.filter(Boolean)
|
|
94
115
|
.map(part => {
|
|
95
116
|
const lower = part.toLowerCase();
|
|
96
|
-
if (lower === "gpt" || lower === "glm" || lower === "ai") return lower.toUpperCase();
|
|
117
|
+
if (lower === "gpt" || lower === "glm" || lower === "ai" || lower === "1m") return lower.toUpperCase();
|
|
97
118
|
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
98
119
|
})
|
|
99
120
|
.join(" ");
|
|
@@ -102,18 +123,64 @@ function displayModelId(modelId: string): string {
|
|
|
102
123
|
function collectDesktop3pModels(
|
|
103
124
|
nativeSlugs: string[],
|
|
104
125
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
126
|
+
profile?: OcxClaudeDesktopProfile,
|
|
105
127
|
): { models: Desktop3pModelEntry[]; registry: Map<string, string> } {
|
|
106
128
|
const registry = new Map<string, string>();
|
|
107
129
|
const models: Desktop3pModelEntry[] = [];
|
|
108
130
|
const candidates: Desktop3pRoutedModel[] = [
|
|
109
|
-
|
|
131
|
+
// Native candidates carry their real context window from the same accessor the
|
|
132
|
+
// Desktop DTO uses, so a native 1M/372k model resolves identically in the written
|
|
133
|
+
// config and on the dashboard.
|
|
134
|
+
...nativeSlugs.map(id => {
|
|
135
|
+
const contextWindow = nativeOpenAiContextWindow(id);
|
|
136
|
+
return { provider: "native", id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
137
|
+
}),
|
|
110
138
|
...routedModels,
|
|
111
139
|
];
|
|
112
140
|
|
|
141
|
+
if (profile) {
|
|
142
|
+
const profileModels = candidates.map(({ provider, id, contextWindow }) => ({
|
|
143
|
+
route: `${provider}/${id}`,
|
|
144
|
+
label: `${displayModelId(id)} (${provider})`,
|
|
145
|
+
...(typeof contextWindow === "number" ? { contextWindow } : {}),
|
|
146
|
+
} satisfies DesktopProfileModel));
|
|
147
|
+
const reconciled = reconcileDesktopProfile(profile, profileModels);
|
|
148
|
+
const rendered = renderDesktopProfile(reconciled, profileModels);
|
|
149
|
+
const aliasesByRoute = new Map<string, string>();
|
|
150
|
+
for (const model of rendered) {
|
|
151
|
+
aliasesByRoute.set(model.route, model.name);
|
|
152
|
+
if (!model.route.startsWith("anthropic/claude-")) registry.set(model.name, model.route);
|
|
153
|
+
models.push({
|
|
154
|
+
name: model.name,
|
|
155
|
+
labelOverride: model.label,
|
|
156
|
+
anthropicFamilyTier: model.family,
|
|
157
|
+
...(model.isFamilyDefault ? { isFamilyDefault: true } : {}),
|
|
158
|
+
...(model.supports1m ? { supports1m: true, prefer1m: true } : {}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Legacy hashes are compatibility-only and can collide. Bind them in stable route order so
|
|
162
|
+
// changing a family default or rendered ordering can never silently rebind an old Desktop id.
|
|
163
|
+
for (const model of [...rendered].sort((a, b) => a.route.localeCompare(b.route))) {
|
|
164
|
+
if (model.route.startsWith("anthropic/claude-")) continue;
|
|
165
|
+
const providerEnd = model.route.indexOf("/");
|
|
166
|
+
const provider = model.route.slice(0, providerEnd);
|
|
167
|
+
const id = model.route.slice(providerEnd + 1);
|
|
168
|
+
const legacy = legacyDesktop3pAlias(provider, id);
|
|
169
|
+
const existing = registry.get(legacy);
|
|
170
|
+
if (existing && existing !== model.route) {
|
|
171
|
+
console.warn(`[opencodex] Claude Desktop legacy alias collision: ${legacy} stays bound to ${existing}; ignoring ${model.route}`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
registry.set(legacy, model.route);
|
|
175
|
+
}
|
|
176
|
+
desktop3pAliasesByRoute = aliasesByRoute;
|
|
177
|
+
return { models, registry };
|
|
178
|
+
}
|
|
179
|
+
|
|
113
180
|
for (const { provider, id, contextWindow } of candidates) {
|
|
114
181
|
const route = `${provider}/${id}`;
|
|
115
182
|
const alias = desktop3pAlias(provider, id);
|
|
116
|
-
const supports1m = typeof contextWindow === "number" && contextWindow >=
|
|
183
|
+
const supports1m = typeof contextWindow === "number" && contextWindow >= DESKTOP_SUPPORTS_1M_THRESHOLD
|
|
117
184
|
? { supports1m: true as const }
|
|
118
185
|
: {};
|
|
119
186
|
if (alias === id) {
|
|
@@ -125,6 +192,7 @@ function collectDesktop3pModels(
|
|
|
125
192
|
labelOverride: `${displayModelId(id)} (${provider})`,
|
|
126
193
|
anthropicFamilyTier: "opus",
|
|
127
194
|
...supports1m,
|
|
195
|
+
...(supports1m.supports1m ? { prefer1m: true as const } : {}),
|
|
128
196
|
});
|
|
129
197
|
continue;
|
|
130
198
|
}
|
|
@@ -143,10 +211,12 @@ function collectDesktop3pModels(
|
|
|
143
211
|
labelOverride: `${displayModelId(id)} (${provider})`,
|
|
144
212
|
anthropicFamilyTier: "opus",
|
|
145
213
|
...supports1m,
|
|
214
|
+
...(supports1m.supports1m ? { prefer1m: true as const } : {}),
|
|
146
215
|
});
|
|
147
216
|
}
|
|
148
217
|
|
|
149
218
|
if (models[0]) models[0].isFamilyDefault = true;
|
|
219
|
+
desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)]));
|
|
150
220
|
return { models, registry };
|
|
151
221
|
}
|
|
152
222
|
|
|
@@ -154,8 +224,9 @@ function collectDesktop3pModels(
|
|
|
154
224
|
export function buildDesktop3pRegistry(
|
|
155
225
|
nativeSlugs: string[],
|
|
156
226
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
227
|
+
profile?: OcxClaudeDesktopProfile,
|
|
157
228
|
): Map<string, string> {
|
|
158
|
-
const { registry } = collectDesktop3pModels(nativeSlugs, routedModels);
|
|
229
|
+
const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile);
|
|
159
230
|
desktop3pRegistry = registry;
|
|
160
231
|
return registry;
|
|
161
232
|
}
|
|
@@ -164,8 +235,9 @@ export function buildDesktop3pRegistry(
|
|
|
164
235
|
export function generateDesktop3pModels(
|
|
165
236
|
nativeSlugs: string[],
|
|
166
237
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
238
|
+
profile?: OcxClaudeDesktopProfile,
|
|
167
239
|
): Desktop3pModelEntry[] {
|
|
168
|
-
const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels);
|
|
240
|
+
const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile);
|
|
169
241
|
desktop3pRegistry = registry;
|
|
170
242
|
return models;
|
|
171
243
|
}
|
|
@@ -175,6 +247,11 @@ export function resolveDesktop3pAlias(alias: string): string | null {
|
|
|
175
247
|
return desktop3pRegistry.get(alias) ?? null;
|
|
176
248
|
}
|
|
177
249
|
|
|
250
|
+
/** Alias selected by the installed profile registry, falling back to the legacy hash shape. */
|
|
251
|
+
export function activeDesktop3pAlias(provider: string, modelId: string): string {
|
|
252
|
+
return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId);
|
|
253
|
+
}
|
|
254
|
+
|
|
178
255
|
/**
|
|
179
256
|
* Generate the complete Claude Desktop 3P gateway config.
|
|
180
257
|
*
|
|
@@ -189,6 +266,7 @@ export function generateDesktop3pConfig(
|
|
|
189
266
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
190
267
|
apiKey = "ocx",
|
|
191
268
|
mode: Desktop3pConfigMode = "static",
|
|
269
|
+
profile?: OcxClaudeDesktopProfile,
|
|
192
270
|
): object {
|
|
193
271
|
const base = {
|
|
194
272
|
inferenceProvider: "gateway",
|
|
@@ -198,13 +276,19 @@ export function generateDesktop3pConfig(
|
|
|
198
276
|
};
|
|
199
277
|
if (mode === "discovery") {
|
|
200
278
|
// Build/refresh the decode registry even though no static list is emitted.
|
|
201
|
-
buildDesktop3pRegistry(nativeSlugs, routedModels);
|
|
279
|
+
buildDesktop3pRegistry(nativeSlugs, routedModels, profile);
|
|
202
280
|
return { ...base, modelDiscoveryEnabled: true };
|
|
203
281
|
}
|
|
204
282
|
return {
|
|
205
283
|
...base,
|
|
206
284
|
modelDiscoveryEnabled: mode === "hybrid",
|
|
207
|
-
inferenceModels:
|
|
285
|
+
inferenceModels: (() => {
|
|
286
|
+
const models = generateDesktop3pModels(nativeSlugs, routedModels, profile);
|
|
287
|
+
// Fail loud at the write boundary rather than ship a config Desktop rejects:
|
|
288
|
+
// the output counterpart of the request-path guards.
|
|
289
|
+
assertDesktop3pModelsValid(models);
|
|
290
|
+
return models;
|
|
291
|
+
})(),
|
|
208
292
|
};
|
|
209
293
|
}
|
|
210
294
|
|
|
@@ -222,8 +306,10 @@ export function writeDesktop3pConfig(
|
|
|
222
306
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
223
307
|
apiKey?: string,
|
|
224
308
|
mode: Desktop3pConfigMode = "static",
|
|
225
|
-
|
|
226
|
-
|
|
309
|
+
profile?: OcxClaudeDesktopProfile,
|
|
310
|
+
): { written: boolean; path: string; reason?: string; fingerprint?: string } {
|
|
311
|
+
const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim()
|
|
312
|
+
|| join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
|
|
227
313
|
const metadataPath = join(libraryPath, "_meta.json");
|
|
228
314
|
let configPath = libraryPath;
|
|
229
315
|
|
|
@@ -238,17 +324,31 @@ export function writeDesktop3pConfig(
|
|
|
238
324
|
? metadata.entries.map(current => current === existing ? entry : current)
|
|
239
325
|
: [...metadata.entries, entry];
|
|
240
326
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
327
|
+
const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile), null, 2) + "\n";
|
|
328
|
+
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
|
|
329
|
+
const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson);
|
|
330
|
+
try {
|
|
331
|
+
atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n");
|
|
332
|
+
} catch (metaError) {
|
|
333
|
+
// Rollback: restore the backed-up config if metadata write fails.
|
|
334
|
+
if (backupPath && existsSync(backupPath)) copyFileSync(backupPath, configPath);
|
|
335
|
+
throw metaError;
|
|
336
|
+
}
|
|
337
|
+
return { written: true, path: configPath, fingerprint };
|
|
250
338
|
} catch (error) {
|
|
251
339
|
const reason = error instanceof Error ? error.message : String(error);
|
|
252
340
|
return { written: false, path: configPath, reason };
|
|
253
341
|
}
|
|
254
342
|
}
|
|
343
|
+
|
|
344
|
+
/** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */
|
|
345
|
+
export function atomicReplaceDesktopConfig(
|
|
346
|
+
path: string,
|
|
347
|
+
content: string,
|
|
348
|
+
writer: (path: string, content: string) => void = atomicWriteFile,
|
|
349
|
+
): { backupPath?: string } {
|
|
350
|
+
const backupPath = `${path}.bak`;
|
|
351
|
+
if (existsSync(path)) copyFileSync(path, backupPath);
|
|
352
|
+
writer(path, content);
|
|
353
|
+
return existsSync(backupPath) ? { backupPath } : {};
|
|
354
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** In-memory Desktop health tracker (resets on server restart). */
|
|
2
|
+
|
|
3
|
+
interface DesktopHealthState {
|
|
4
|
+
lastRequestAt: number | null;
|
|
5
|
+
requestCount: number;
|
|
6
|
+
errorCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const state: DesktopHealthState = { lastRequestAt: null, requestCount: 0, errorCount: 0 };
|
|
10
|
+
|
|
11
|
+
export function recordDesktopRequest(): void {
|
|
12
|
+
state.lastRequestAt = Date.now();
|
|
13
|
+
state.requestCount++;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function recordDesktopError(): void {
|
|
17
|
+
state.errorCount++;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getDesktopHealth(): { lastRequestAt: string | null; requestCount: number; errorCount: number } {
|
|
21
|
+
return {
|
|
22
|
+
lastRequestAt: state.lastRequestAt !== null ? new Date(state.lastRequestAt).toISOString() : null,
|
|
23
|
+
requestCount: state.requestCount,
|
|
24
|
+
errorCount: state.errorCount,
|
|
25
|
+
};
|
|
26
|
+
}
|