@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
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type OcxProviderConfig,
16
16
  } from "./types";
17
17
  import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers";
18
+ import { parseDesktopProfile } from "./claude/desktop-profile";
18
19
 
19
20
  let _atomicSeq = 0;
20
21
 
@@ -485,11 +486,27 @@ const configSchema = z.object({
485
486
  contextCapValue: z.number().int().positive().optional(),
486
487
  multiAgentGuidanceEnabled: z.boolean().optional(),
487
488
  codexShimAutoRestore: z.boolean().optional(),
489
+ // Model ids excluded from the Grok Build managed block (dashboard switches).
490
+ grokExcludedModels: z.array(z.string()).optional(),
488
491
  // Invalid values degrade to undefined ("auto") instead of failing the whole
489
492
  // parse: a hand-edited typo must never trip the backup-and-defaults repair
490
493
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
491
494
  streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
492
495
  }).passthrough().superRefine((config, ctx) => {
496
+ const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
497
+ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
498
+ ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" });
499
+ } else if (claudeCode && "desktopProfile" in claudeCode && (claudeCode as { desktopProfile?: unknown }).desktopProfile !== undefined) {
500
+ try {
501
+ parseDesktopProfile((claudeCode as { desktopProfile?: unknown }).desktopProfile);
502
+ } catch (error) {
503
+ ctx.addIssue({
504
+ code: "custom",
505
+ path: ["claudeCode", "desktopProfile"],
506
+ message: error instanceof Error ? error.message : String(error),
507
+ });
508
+ }
509
+ }
493
510
  for (const name of Object.keys(config.providers)) {
494
511
  if (!isValidProviderName(name)) {
495
512
  ctx.addIssue({
@@ -845,6 +862,106 @@ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolea
845
862
  return config.websockets === true;
846
863
  }
847
864
 
865
+ // ---------------------------------------------------------------------------
866
+ // Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1).
867
+ //
868
+ // `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model
869
+ // visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode`
870
+ // from whatever the long-lived server config happens to hold. A user who hand-edits
871
+ // `config.json` while the proxy runs then watches their edit vanish for no visible
872
+ // reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has
873
+ // to live in ONE save wrapper that every live-config writer goes through.
874
+ // ---------------------------------------------------------------------------
875
+
876
+ /**
877
+ * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()`
878
+ * elsewhere must not refresh the baseline the long-lived server config is judged
879
+ * against, or a later stale save would masquerade as "our own change".
880
+ */
881
+ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
882
+
883
+ /**
884
+ * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
885
+ * first save — arming lazily would lose exactly the hand edit made before that first
886
+ * save, which is the case the guard exists for.
887
+ */
888
+ export function armClaudeCodeBaseline(config: OcxConfig): void {
889
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
890
+ }
891
+
892
+ /** Test seam only: is this instance armed? */
893
+ export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
894
+ return claudeCodeBaseline.has(config);
895
+ }
896
+
897
+ /**
898
+ * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not
899
+ * decide whether a user's hand edit survives.
900
+ */
901
+ function deepEqual(a: unknown, b: unknown): boolean {
902
+ if (a === b) return true;
903
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
904
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
905
+ if (Array.isArray(a) && Array.isArray(b)) {
906
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
907
+ }
908
+ const left = a as Record<string, unknown>;
909
+ const right = b as Record<string, unknown>;
910
+ // `undefined` values and absent keys are the same thing after a JSON round-trip.
911
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
912
+ for (const key of keys) {
913
+ if (left[key] === undefined && right[key] === undefined) continue;
914
+ if (!deepEqual(left[key], right[key])) return false;
915
+ }
916
+ return true;
917
+ }
918
+
919
+ /** The literal file, with no schema merge or default injection. */
920
+ function readRawConfigJson(): Record<string, unknown> | undefined {
921
+ try {
922
+ const configPath = getConfigPath();
923
+ if (!existsSync(configPath)) return undefined;
924
+ const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
925
+ const parsed = JSON.parse(raw) as unknown;
926
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
927
+ return parsed as Record<string, unknown>;
928
+ } catch {
929
+ // Unreadable or corrupt: behave exactly as before. Never fail a save over protection.
930
+ return undefined;
931
+ }
932
+ }
933
+
934
+ /**
935
+ * The save entry point for every writer holding a LIVE server config.
936
+ *
937
+ * Conflict policy, chosen deliberately:
938
+ * - disk changed, we did not → their hand edit wins;
939
+ * - disk changed AND we changed → our change wins and the baseline rebases, so the
940
+ * user's next edit starts from the new value (a three-way merge is out of scope);
941
+ * - file missing/unreadable → save what we have, no throw.
942
+ *
943
+ * Scope residual: only `claudeCode` is reconciled. A hand edit to `providers` is still
944
+ * clobbered — recorded and asserted in tests so it cannot drift into an assumed
945
+ * guarantee.
946
+ */
947
+ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
948
+ if (claudeCodeBaseline.has(config)) {
949
+ const onDisk = readRawConfigJson();
950
+ if (onDisk !== undefined) {
951
+ const baseline = claudeCodeBaseline.get(config);
952
+ const diskChanged = !deepEqual(onDisk.claudeCode, baseline);
953
+ const weChanged = !deepEqual(config.claudeCode, baseline);
954
+ if (diskChanged && !weChanged) {
955
+ config.claudeCode = onDisk.claudeCode as OcxConfig["claudeCode"];
956
+ }
957
+ }
958
+ }
959
+ saveConfig(config);
960
+ if (claudeCodeBaseline.has(config)) {
961
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
962
+ }
963
+ }
964
+
848
965
  export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
849
966
  return config.codexAutoStart !== false;
850
967
  }
@@ -0,0 +1,339 @@
1
+ import { constants, copyFileSync, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { atomicWriteFile } from "../config";
5
+ import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject";
6
+
7
+ export interface GrokInjectModel {
8
+ id: string;
9
+ name?: string;
10
+ contextWindow?: number;
11
+ }
12
+
13
+ export interface GrokInjectResult {
14
+ ok: boolean;
15
+ changed: boolean;
16
+ message: string;
17
+ skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback";
18
+ }
19
+
20
+ const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
21
+ const END_MARKER = "# <<< opencodex managed block <<<";
22
+ // grok 0.2.101 verified live (2026-07-23): [model_providers.<id>] inheritance parses but the
23
+ // inherited base_url is NOT applied to inference routing — the turn falls through to the default
24
+ // cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its
25
+ // own base_url/api_backend/api_key and no [model_providers] table is emitted at all.
26
+
27
+ interface ManagedRegion {
28
+ start: number;
29
+ end: number;
30
+ orphaned: boolean;
31
+ }
32
+
33
+ function tomlString(value: string): string {
34
+ return JSON.stringify(value);
35
+ }
36
+
37
+ function resolveGrokHome(grokHome?: string): string {
38
+ return grokHome ?? (process.env.GROK_HOME || join(homedir(), ".grok"));
39
+ }
40
+
41
+ function isDirectory(path: string): boolean {
42
+ try {
43
+ return statSync(path).isDirectory();
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function findManagedRegion(content: string): ManagedRegion | null {
50
+ const start = content.indexOf(BEGIN_MARKER);
51
+ if (start === -1) return null;
52
+ const endMarkerStart = content.indexOf(END_MARKER, start + BEGIN_MARKER.length);
53
+ if (endMarkerStart === -1) return { start, end: content.length, orphaned: true };
54
+ return { start, end: endMarkerStart + END_MARKER.length, orphaned: false };
55
+ }
56
+
57
+ /**
58
+ * A TOML key segment as it may be spelled in a table header: bare, basic string, or literal
59
+ * string. All three spellings of the same key address the SAME table, so both segments of a
60
+ * `[model.<alias>]` header must be canonicalized before comparison.
61
+ */
62
+ const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
63
+ /**
64
+ * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table
65
+ * (`[model.x.sub]`) spellings. `[[model.x]]` genuinely collides with a generated `[model.x]`,
66
+ * and one collision makes grok reject the ENTIRE config layer ("duplicate key"), taking every
67
+ * unrelated user setting with it; `[model.x.sub]` does not strictly collide, but reserving it
68
+ * costs only a suffixed alias and keeps us clear of the user's namespace.
69
+ *
70
+ * Every character class here is newline-free ON PURPOSE. With `[^\]]*` the optional sub-table
71
+ * tail runs past the end of its own line, so an unclosed `[model.…` inside a multiline string
72
+ * swallows the following lines — including a real `[model.<alias>]` header, which then goes
73
+ * unreserved and produces the very duplicate-key config this scan exists to prevent.
74
+ */
75
+ const MODEL_TABLE_HEADER = new RegExp(
76
+ String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`,
77
+ "gm",
78
+ );
79
+
80
+ /** Resolve a header key segment (bare / basic / literal) to the key it actually addresses. */
81
+ function canonicalKeySegment(raw: string): string {
82
+ if (raw.startsWith('"')) return decodeTomlBasicString(raw.slice(1, -1));
83
+ if (raw.startsWith("'")) return raw.slice(1, -1); // literal strings have no escapes
84
+ return raw;
85
+ }
86
+
87
+ /**
88
+ * `[model.<alias>]` table headers the USER owns (outside our fence) — reserved for collisions.
89
+ * TOML admits equivalent header spellings for BOTH segments (`["model"."ocx-mine"]`,
90
+ * `['model'.ocx-mine]`, `[ model . ocx-mine ]`); all of them redefine the same table, so each
91
+ * form is canonicalized before it is reserved.
92
+ */
93
+ function userModelAliases(content: string, region: ManagedRegion | null): Set<string> {
94
+ const outsideManagedRegion = region
95
+ ? content.slice(0, region.start) + content.slice(region.end)
96
+ : content;
97
+ const aliases = new Set<string>();
98
+ for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) {
99
+ if (canonicalKeySegment(match[1]!) !== "model") continue;
100
+ aliases.add(canonicalKeySegment(match[2]!));
101
+ }
102
+ return aliases;
103
+ }
104
+
105
+ function orphanedMarkerResult(action: string): GrokInjectResult {
106
+ return {
107
+ ok: false,
108
+ changed: false,
109
+ message: `Grok config ${action} refused: found the opencodex begin marker without its end marker. `
110
+ + "The managed region boundary is ambiguous, so nothing was modified. "
111
+ + "Repair ~/.grok/config.toml manually (see config.toml.bak-opencodex) and re-run.",
112
+ skippedReason: "orphaned-marker",
113
+ };
114
+ }
115
+
116
+ function copyBackupOnce(configPath: string, backupPath: string): void {
117
+ if (existsSync(backupPath)) return;
118
+ try {
119
+ copyFileSync(configPath, backupPath, constants.COPYFILE_EXCL);
120
+ } catch (error) {
121
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
122
+ }
123
+ }
124
+
125
+ function errorResult(action: string, error: unknown): GrokInjectResult {
126
+ const detail = error instanceof Error ? error.message : String(error);
127
+ return { ok: false, changed: false, message: `Could not ${action} Grok config: ${detail}` };
128
+ }
129
+
130
+ export function buildGrokManagedBlock(
131
+ port: number,
132
+ models: GrokInjectModel[],
133
+ hostname?: string,
134
+ reservedAliases?: ReadonlySet<string>,
135
+ /**
136
+ * Ids to allocate an alias for but NOT emit. Alias numbering must not depend on which
137
+ * models the user switched off, or excluding one colliding model would rename another
138
+ * model's alias out from under a grok config that already uses it.
139
+ */
140
+ excluded?: ReadonlySet<string>,
141
+ ): string {
142
+ const host = providerBaseHost(hostname);
143
+ const baseUrl = `http://${host}:${port}/v1`;
144
+ const lines = [
145
+ BEGIN_MARKER,
146
+ ];
147
+ const aliasCounts = new Map<string, number>();
148
+ const taken = new Set(reservedAliases ?? []);
149
+
150
+ for (const model of models) {
151
+ const baseAlias = `ocx-${model.id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
152
+ let count = (aliasCounts.get(baseAlias) ?? 0) + 1;
153
+ let alias = count === 1 ? baseAlias : `${baseAlias}-${count}`;
154
+ // User-owned [model.<alias>] tables outside the fence are reserved: emitting a
155
+ // duplicate table header would make the whole TOML invalid for grok.
156
+ while (taken.has(alias)) {
157
+ count += 1;
158
+ alias = `${baseAlias}-${count}`;
159
+ }
160
+ aliasCounts.set(baseAlias, count);
161
+ taken.add(alias);
162
+ // Slot consumed, table not written: this is what keeps every other alias stable
163
+ // across selection changes.
164
+ if (excluded?.has(model.id)) continue;
165
+ const isFirst = lines.length === 1;
166
+ lines.push(
167
+ ...(isFirst ? [] : [""]),
168
+ `[model.${alias}]`,
169
+ `model = ${tomlString(model.id)}`,
170
+ `base_url = ${tomlString(baseUrl)}`,
171
+ 'api_backend = "chat_completions"',
172
+ 'api_key = "opencodex-loopback"',
173
+ `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`,
174
+ // Best-effort attribution tag for the usage dashboard. Upstream Grok sends
175
+ // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a
176
+ // security boundary — any loopback client could send the same header.
177
+ 'extra_headers = { "x-opencodex-grok" = "1" }',
178
+ );
179
+ if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) {
180
+ lines.push(`context_window = ${model.contextWindow}`);
181
+ }
182
+ }
183
+
184
+ lines.push(END_MARKER);
185
+ return lines.join("\n");
186
+ }
187
+
188
+ export function injectGrokConfig(
189
+ port: number,
190
+ models: GrokInjectModel[],
191
+ opts: { grokHome?: string; hostname?: string; excluded?: ReadonlySet<string> } = {},
192
+ ): GrokInjectResult {
193
+ const grokHome = resolveGrokHome(opts.grokHome);
194
+ if (!isDirectory(grokHome)) {
195
+ return {
196
+ ok: true,
197
+ changed: false,
198
+ message: `Grok home not found at ${grokHome}; config injection skipped.`,
199
+ skippedReason: "no-grok-home",
200
+ };
201
+ }
202
+
203
+ // Non-loopback binds require the real admission token (src/server/auth-cors.ts), and there is
204
+ // no safe way for a REGENERATED block to carry it: a literal token would write the user's
205
+ // secret into their own file and overwrite it on every start/ensure/restart, while omitting
206
+ // api_key in favour of env_key opens grok's credential fallthrough — with no `model_provider`
207
+ // to fail closed, an unresolved env_key makes grok send its xAI session bearer to our
208
+ // plaintext LAN endpoint (upstream config.rs resolve_credentials). So we do not auto-register
209
+ // at all here; the user configures models manually, outside our fence, where nothing we do
210
+ // can clobber their credential.
211
+ if (!isLoopbackHostname(opts.hostname)) {
212
+ const removed = stripGrokConfig({ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}) });
213
+ const cleanup = removed.changed
214
+ ? " Removed the previously generated block, which pointed at a loopback address."
215
+ : "";
216
+ return {
217
+ ok: true, // a deliberate policy skip, not a failure — it must never block startup
218
+ changed: removed.changed,
219
+ skippedReason: "non-loopback",
220
+ message: `Grok auto-registration skipped: opencodex is bound to the non-loopback host `
221
+ + `"${opts.hostname}", where requests need your admission token. A managed block would `
222
+ + `either store that secret in ~/.grok/config.toml or overwrite it on the next start, so `
223
+ + `add the models yourself OUTSIDE the opencodex markers (see the Grok Build guide).${cleanup}`,
224
+ };
225
+ }
226
+
227
+ const configPath = join(grokHome, "config.toml");
228
+ const backupPath = join(grokHome, "config.toml.bak-opencodex");
229
+ try {
230
+ const configExisted = existsSync(configPath);
231
+ const rawContent = configExisted ? readFileSync(configPath, "utf8") : "";
232
+ const eol = dominantEol(rawContent);
233
+ const content = applyEol(rawContent, "\n");
234
+ const region = findManagedRegion(content);
235
+ if (region?.orphaned) return orphanedMarkerResult("injection");
236
+
237
+ const block = buildGrokManagedBlock(port, models, opts.hostname, userModelAliases(content, region), opts.excluded);
238
+ let nextContent: string;
239
+ if (region) {
240
+ nextContent = content.slice(0, region.start) + block + content.slice(region.end);
241
+ } else if (content.length === 0) {
242
+ nextContent = `${block}\n`;
243
+ } else {
244
+ // Exactly ONE separator newline, always. The old rule ("\n\n" when the file lacked a
245
+ // trailing newline) made two different originals — "X" and "X\n" — produce byte-identical
246
+ // files, so strip could not restore both. One newline keeps injection injective: the
247
+ // user's own terminator is preserved verbatim and strip can undo exactly what we added.
248
+ nextContent = `${content}\n${block}\n`;
249
+ }
250
+
251
+ const output = applyEol(nextContent, eol);
252
+ if (output === rawContent) {
253
+ return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." };
254
+ }
255
+ if (configExisted && !region) copyBackupOnce(configPath, backupPath);
256
+ atomicWriteFile(configPath, output);
257
+ return {
258
+ ok: true,
259
+ changed: true,
260
+ message: region
261
+ ? "Updated the opencodex managed block in Grok config."
262
+ : "Added the opencodex managed block to Grok config.",
263
+ };
264
+ } catch (error) {
265
+ return errorResult("inject", error);
266
+ }
267
+ }
268
+
269
+ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectResult {
270
+ const grokHome = resolveGrokHome(opts.grokHome);
271
+ if (!isDirectory(grokHome)) {
272
+ return {
273
+ ok: true,
274
+ changed: false,
275
+ message: `Grok home not found at ${grokHome}; no managed config to remove.`,
276
+ skippedReason: "no-grok-home",
277
+ };
278
+ }
279
+
280
+ const configPath = join(grokHome, "config.toml");
281
+ if (!existsSync(configPath)) {
282
+ return { ok: true, changed: false, message: "Grok config not found; no managed block to remove." };
283
+ }
284
+
285
+ try {
286
+ const rawContent = readFileSync(configPath, "utf8");
287
+ const eol = dominantEol(rawContent);
288
+ const content = applyEol(rawContent, "\n");
289
+ const region = findManagedRegion(content);
290
+ if (!region) {
291
+ return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
292
+ }
293
+ if (region.orphaned) return orphanedMarkerResult("cleanup");
294
+
295
+ let removalEnd = region.end;
296
+ if (content.startsWith("\n", removalEnd)) removalEnd += 1;
297
+ let prefix = content.slice(0, region.start);
298
+ const restOfFile = content.slice(removalEnd);
299
+ // Undo the single separator newline injection added. Two cases, mirroring inject:
300
+ // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
301
+ // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
302
+ // A block the user has appended content after is left alone: we never shrink their bytes.
303
+ if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
304
+ else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
305
+ const stripped = prefix + restOfFile;
306
+ atomicWriteFile(configPath, applyEol(stripped, eol));
307
+
308
+ return {
309
+ ok: true,
310
+ changed: true,
311
+ message: "Removed the opencodex managed block from Grok config.",
312
+ };
313
+ } catch (error) {
314
+ return errorResult("strip", error);
315
+ }
316
+ }
317
+ /** Decode a TOML basic-string body: JSON-compatible escapes plus TOML's \uXXXX / \UXXXXXXXX. */
318
+ function decodeTomlBasicString(body: string): string {
319
+ return body.replace(
320
+ /\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g,
321
+ (whole, esc: string) => {
322
+ if (esc[0] === "u") return String.fromCharCode(parseInt(esc.slice(1), 16));
323
+ if (esc[0] === "U") {
324
+ const code = parseInt(esc.slice(1), 16);
325
+ return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
326
+ }
327
+ switch (esc) {
328
+ case "b": return "\b";
329
+ case "t": return "\t";
330
+ case "n": return "\n";
331
+ case "f": return "\f";
332
+ case "r": return "\r";
333
+ case '"': return '"';
334
+ case "\\": return "\\";
335
+ default: return whole; // invalid escape — keep raw, reservation stays conservative
336
+ }
337
+ },
338
+ );
339
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Read-only view of the Grok Build managed block.
3
+ *
4
+ * This never writes. `injectGrokConfig` owns every mutation of `~/.grok/config.toml`, behind
5
+ * guards (non-loopback refusal, byte-for-byte preservation of user content, alias reservation)
6
+ * that a web-reachable writer would widen the blast radius of. The dashboard only needs to
7
+ * answer "is Grok wired up, and with what?", which a reader does at a fraction of the risk.
8
+ *
9
+ * It parses only the fenced region we ourselves emit, and only the specific fields
10
+ * `buildGrokManagedBlock` writes — user content outside the fence is never read or echoed,
11
+ * since it can legitimately contain real credentials.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
18
+ const END_MARKER = "# <<< opencodex managed block <<<";
19
+
20
+ export interface GrokStatusModel {
21
+ /** Alias of the emitted `[model.<alias>]` table. */
22
+ alias: string;
23
+ /** The model id opencodex routes for. */
24
+ id: string;
25
+ contextWindow?: number;
26
+ }
27
+
28
+ export interface GrokStatus {
29
+ configPath: string;
30
+ /** Whether the managed fence is present in that file. */
31
+ present: boolean;
32
+ /** Endpoint the fence points at, parsed from the first entry. */
33
+ baseUrl: string | null;
34
+ models: GrokStatusModel[];
35
+ }
36
+
37
+ /** Mirrors `resolveGrokHome` in ./inject so both agree on the authoritative file. */
38
+ export function grokConfigPath(grokHome?: string): string {
39
+ const home = grokHome ?? (process.env.GROK_HOME || join(homedir(), ".grok"));
40
+ return join(home, "config.toml");
41
+ }
42
+
43
+ function tomlStringValue(line: string): string | undefined {
44
+ const match = /^[A-Za-z_]+\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/.exec(line.trim());
45
+ if (!match) return undefined;
46
+ return match[1]!.replace(/\\(["\\])/g, "$1");
47
+ }
48
+
49
+ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
50
+ const configPath = grokConfigPath(opts.grokHome);
51
+ let content: string;
52
+ try {
53
+ content = readFileSync(configPath, "utf8");
54
+ } catch {
55
+ // No Grok install, or no config yet. Absent is a state, not an error.
56
+ return { configPath, present: false, baseUrl: null, models: [] };
57
+ }
58
+
59
+ const begin = content.indexOf(BEGIN_MARKER);
60
+ const end = content.indexOf(END_MARKER, begin + 1);
61
+ if (begin < 0 || end < 0) return { configPath, present: false, baseUrl: null, models: [] };
62
+
63
+ const region = content.slice(begin + BEGIN_MARKER.length, end);
64
+ const models: GrokStatusModel[] = [];
65
+ let baseUrl: string | null = null;
66
+ let current: GrokStatusModel | null = null;
67
+
68
+ for (const rawLine of region.split("\n")) {
69
+ const line = rawLine.trim();
70
+ const header = /^\[model\.([^\]]+)\]$/.exec(line);
71
+ if (header) {
72
+ current = { alias: header[1]!, id: "" };
73
+ models.push(current);
74
+ continue;
75
+ }
76
+ if (!current) continue;
77
+ if (line.startsWith("model =")) {
78
+ current.id = tomlStringValue(line) ?? "";
79
+ } else if (line.startsWith("base_url =")) {
80
+ baseUrl ??= tomlStringValue(line) ?? null;
81
+ } else if (line.startsWith("context_window =")) {
82
+ const value = Number(line.slice(line.indexOf("=") + 1).trim());
83
+ if (Number.isFinite(value) && value > 0) current.contextWindow = value;
84
+ }
85
+ }
86
+
87
+ return { configPath, present: true, baseUrl, models: models.filter(model => model.id) };
88
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared Grok Build config sync: gather the visible model catalog and (re)inject the
3
+ * managed block into ~/.grok/config.toml. Used by `ocx start` (server process) and by
4
+ * `ocx ensure` / `ocx restart` (parent process, after live discovery or child readiness)
5
+ * so the fence exists deterministically once the proxy reports healthy.
6
+ *
7
+ * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy.
8
+ */
9
+ import { visibleNativeSlugs, filterCatalogVisibleModels, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog";
10
+ import type { OcxConfig } from "../types";
11
+ import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject";
12
+
13
+ export interface GrokSyncDeps {
14
+ fetchAllModels: (config: OcxConfig) => Promise<CatalogModel[]>;
15
+ injectGrokConfig: typeof injectGrokConfig;
16
+ }
17
+
18
+ async function defaultFetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
19
+ const { fetchAllModels } = await import("../server/management-api");
20
+ return fetchAllModels(config);
21
+ }
22
+
23
+ /**
24
+ * Build the model list and inject the fenced block. `hostname` should be the hostname the
25
+ * RUNNING proxy actually bound (live.hostname from proxy-liveness for ensure's live branch;
26
+ * config.hostname for a freshly spawned start) — a stale config.hostname could otherwise
27
+ * name a host the process never bound.
28
+ */
29
+ export async function syncGrokConfig(
30
+ port: number,
31
+ config: OcxConfig,
32
+ opts: { hostname?: string; grokHome?: string } = {},
33
+ deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig },
34
+ ): Promise<GrokInjectResult> {
35
+ let models: GrokInjectModel[];
36
+ try {
37
+ const routed = filterCatalogVisibleModels(await deps.fetchAllModels(config), config);
38
+ models = [
39
+ // Native slugs carry their context window too. Without it Grok falls back to its own
40
+ // default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same
41
+ // accessor the dashboard's native rows use, so the two cannot disagree.
42
+ ...visibleNativeSlugs(config).map(id => {
43
+ const contextWindow = nativeOpenAiContextWindow(id);
44
+ return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
45
+ }),
46
+ ...routed.map(m => ({
47
+ id: m.alias ?? `${m.provider}/${m.id}`,
48
+ ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
49
+ })),
50
+ ];
51
+ } catch (err) {
52
+ return {
53
+ ok: false,
54
+ changed: false,
55
+ message: `Grok config sync skipped: model catalog unavailable (${err instanceof Error ? err.message : String(err)})`,
56
+ };
57
+ }
58
+ // Pass the FULL list plus the exclusion set: the writer allocates aliases over
59
+ // everything and emits only what is switched on, so a model's alias never depends on
60
+ // its neighbours' switches. Absent/empty selection keeps today's behaviour exactly.
61
+ return deps.injectGrokConfig(port, models, {
62
+ ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
63
+ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}),
64
+ excluded: new Set(config.grokExcludedModels ?? []),
65
+ });
66
+ }
@@ -133,10 +133,15 @@ export function assertProviderDestinationAllowed(name: string, provider: Pick<Oc
133
133
  * config load are synchronous). DNS failures return null: config-time validation is
134
134
  * advisory and must not hard-fail offline startups. DNS rebinding after validation is
135
135
  * a recorded residual for this loopback proxy (devlog 260712_pr_batch_landing 000).
136
+ *
137
+ * `allowBenchmarkAddresses` is only for the exact canonical ChatGPT Codex seed under
138
+ * Clash fake-IP DNS (198.18.0.0/15). Every other non-public answer — including mixed
139
+ * benchmark + private/metadata sets — still fails.
136
140
  */
137
141
  export async function providerDestinationResolvedError(
138
142
  name: string,
139
143
  provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">,
144
+ options?: { allowBenchmarkAddresses?: boolean },
140
145
  ): Promise<string | null> {
141
146
  const syncError = providerDestinationConfigError(name, provider);
142
147
  if (syncError) return syncError;
@@ -160,6 +165,14 @@ export async function providerDestinationResolvedError(
160
165
  const ipKind = isIP(address);
161
166
  const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
162
167
  if (!assessment || assessment.kind === "public") continue;
168
+ // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject.
169
+ if (
170
+ options?.allowBenchmarkAddresses
171
+ && assessment.kind === "private"
172
+ && assessment.detail === "benchmark address"
173
+ ) {
174
+ continue;
175
+ }
163
176
  if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
164
177
  return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
165
178
  }