@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
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
|
+
}
|
package/src/grok/sync.ts
ADDED
|
@@ -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
|
}
|