@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.
Files changed (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. 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
+ }
@@ -1,18 +1,29 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- const SUPPORTS_1M_THRESHOLD = 1_000_000;
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
- ...nativeSlugs.map(id => ({ provider: "native", id })),
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 >= SUPPORTS_1M_THRESHOLD
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: generateDesktop3pModels(nativeSlugs, routedModels),
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
- ): { written: boolean; path: string; reason?: string } {
226
- const libraryPath = join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
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
- writeFileSync(configPath, JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode), null, 2) + "\n", {
242
- encoding: "utf8",
243
- mode: 0o600,
244
- });
245
- writeFileSync(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n", {
246
- encoding: "utf8",
247
- mode: 0o600,
248
- });
249
- return { written: true, path: configPath };
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
+ }