@bitkyc08/opencodex 2.7.18 → 2.7.20
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.ko.md +2 -1
- package/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/gui/dist/assets/index-BH-RbAg4.js +40 -0
- package/gui/dist/assets/index-BpqkH5f_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +16 -4
- package/src/adapters/cursor/native-exec-desktop.ts +10 -3
- package/src/adapters/mimo-free.ts +228 -0
- package/src/adapters/openai-chat.ts +104 -1
- package/src/claude/inbound.ts +3 -1
- package/src/cli/claude.ts +21 -2
- package/src/cli/v2.ts +21 -5
- package/src/codex/catalog.ts +14 -1
- package/src/codex/project-config-warnings.ts +18 -6
- package/src/lib/win-exec.ts +105 -0
- package/src/oauth/callback-server.ts +17 -7
- package/src/oauth/index.ts +93 -0
- package/src/oauth/types.ts +1 -1
- package/src/providers/derive.ts +4 -0
- package/src/providers/registry.ts +37 -1
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/management-api.ts +34 -4
- package/src/server/relay.ts +6 -2
- package/src/server/request-log.ts +4 -1
- package/gui/dist/assets/index-BUBsQALh.css +0 -1
- package/gui/dist/assets/index-DEbBFENM.js +0 -40
package/src/codex/catalog.ts
CHANGED
|
@@ -1190,7 +1190,8 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1190
1190
|
provider: name,
|
|
1191
1191
|
owned_by: m.owned_by,
|
|
1192
1192
|
...catalogHintsFromModelsApiItem(name, m),
|
|
1193
|
-
}, contextCap))
|
|
1193
|
+
}, contextCap))
|
|
1194
|
+
.filter(m => shouldExposeProviderModel(name, m.id));
|
|
1194
1195
|
const liveIds = new Set(live.map(m => m.id));
|
|
1195
1196
|
// Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
|
|
1196
1197
|
// ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
|
|
@@ -1204,6 +1205,8 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1204
1205
|
if (dated) {
|
|
1205
1206
|
// Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
|
|
1206
1207
|
live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
|
|
1208
|
+
} else if (shouldRetainConfiguredProviderModel(name, m.id)) {
|
|
1209
|
+
live.push(m);
|
|
1207
1210
|
} else {
|
|
1208
1211
|
droppedConfiguredIds.push(m.id);
|
|
1209
1212
|
}
|
|
@@ -1224,6 +1227,16 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1224
1227
|
}
|
|
1225
1228
|
}
|
|
1226
1229
|
|
|
1230
|
+
function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
|
|
1231
|
+
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
|
|
1236
|
+
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1227
1240
|
/**
|
|
1228
1241
|
* Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
|
|
1229
1242
|
* `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import path, { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { expandUserPath } from "../config";
|
|
4
4
|
import { defaultCodexHome } from "./home";
|
|
5
5
|
import { readRootTomlString } from "./paths";
|
|
@@ -223,12 +223,24 @@ export function dedupeRelatedProjectCodexWarnings(
|
|
|
223
223
|
});
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Render a path under the user's home as `~/...` for warning display.
|
|
228
|
+
* Platform-correct containment (devlog 260715_cross_platform_audit/030): the old
|
|
229
|
+
* lowercase prefix match had no component boundary (`C:\Users\bob2` rendered as
|
|
230
|
+
* inside `~` for home `C:\Users\bob`) and case-folded on case-sensitive POSIX
|
|
231
|
+
* filesystems. `relative()` carries the right case semantics per platform; reject
|
|
232
|
+
* parent (`..`, `..\x`) and cross-drive (absolute) results.
|
|
233
|
+
*/
|
|
234
|
+
export function relPath(
|
|
235
|
+
abs: string,
|
|
236
|
+
pathApi: Pick<typeof path, "relative" | "sep" | "isAbsolute"> = path,
|
|
237
|
+
): string {
|
|
227
238
|
const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
|
228
|
-
if (home
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return abs;
|
|
239
|
+
if (!home) return abs;
|
|
240
|
+
const rel = pathApi.relative(home, abs);
|
|
241
|
+
if (rel === "") return "~";
|
|
242
|
+
if (rel === ".." || rel.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(rel)) return abs;
|
|
243
|
+
return `~/${rel.replace(/\\/g, "/")}`;
|
|
232
244
|
}
|
|
233
245
|
|
|
234
246
|
export function discoverProjectCodexConfigPaths(options: {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform command launching (devlog 260715_cross_platform_audit/020).
|
|
3
|
+
*
|
|
4
|
+
* Windows npm installs expose CLIs as `.cmd` shims, and Node/Bun refuse shell-less
|
|
5
|
+
* `.cmd` spawns (CVE-2024-27980 hardening). Bare names like `spawn("claude")` also
|
|
6
|
+
* skip PATHEXT resolution entirely, so they ENOENT even when `claude.cmd` is on PATH.
|
|
7
|
+
* This module mirrors the battle-tested cross-spawn approach: resolve the real target
|
|
8
|
+
* via PATH×PATHEXT, launch `.exe` targets directly (argument boundaries preserved by
|
|
9
|
+
* the normal shell-less spawn), and route `.cmd`/`.bat` targets through
|
|
10
|
+
* `cmd.exe /d /s /c "<escaped line>"` with `windowsVerbatimArguments: true`.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { win32 } from "node:path";
|
|
14
|
+
|
|
15
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
16
|
+
/** cross-spawn parse.js: only npm local-bin shims get double escaping. */
|
|
17
|
+
const IS_CMD_SHIM = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;
|
|
18
|
+
|
|
19
|
+
/** cross-spawn escape.js argument(): quote + escape one argument for cmd.exe /d /s /c. */
|
|
20
|
+
export function escapeCmdArg(arg: string, doubleEscape = false): string {
|
|
21
|
+
let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
22
|
+
out = `"${out}"`.replace(CMD_META, "^$1");
|
|
23
|
+
return doubleEscape ? out.replace(CMD_META, "^$1") : out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** cross-spawn escape.js command(): escape the command token itself (no quoting). */
|
|
27
|
+
export function escapeCmdCommand(command: string): string {
|
|
28
|
+
return command.replace(CMD_META, "^$1");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolveDeps {
|
|
32
|
+
env?: Record<string, string | undefined>;
|
|
33
|
+
exists?: (path: string) => boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a bare command name to its first PATH×PATHEXT hit (win32 semantics).
|
|
38
|
+
* Commands that already carry an extension, a separator, or an absolute prefix are
|
|
39
|
+
* returned unchanged; unresolvable names fall back unchanged (spawn will surface it).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}): string {
|
|
42
|
+
const env = deps.env ?? process.env;
|
|
43
|
+
const exists = deps.exists ?? existsSync;
|
|
44
|
+
if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
|
|
45
|
+
return command;
|
|
46
|
+
}
|
|
47
|
+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
48
|
+
for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
|
|
49
|
+
for (const ext of exts) {
|
|
50
|
+
const candidate = win32.join(dir, command + ext.toLowerCase());
|
|
51
|
+
if (exists(candidate)) return candidate;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return command;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SpawnInvocation {
|
|
58
|
+
file: string;
|
|
59
|
+
args: string[];
|
|
60
|
+
options: { windowsVerbatimArguments?: boolean };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Platform-safe invocation preserving argument boundaries (cross-spawn parse.js).
|
|
65
|
+
* POSIX: passthrough. win32 `.exe`: resolved direct spawn. win32 `.cmd`/`.bat`:
|
|
66
|
+
* `ComSpec /d /s /c "<escaped command line>"` with verbatim args; npm local-bin
|
|
67
|
+
* shims get cross-spawn's double escaping, all other batch targets single.
|
|
68
|
+
*/
|
|
69
|
+
export function commandInvocation(
|
|
70
|
+
command: string,
|
|
71
|
+
args: readonly string[],
|
|
72
|
+
platform: NodeJS.Platform = process.platform,
|
|
73
|
+
deps: ResolveDeps = {},
|
|
74
|
+
): SpawnInvocation {
|
|
75
|
+
if (platform !== "win32") return { file: command, args: [...args], options: {} };
|
|
76
|
+
const resolved = resolveWindowsCommand(command, deps);
|
|
77
|
+
if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args: [...args], options: {} };
|
|
78
|
+
const env = deps.env ?? process.env;
|
|
79
|
+
const doubleEscape = IS_CMD_SHIM.test(resolved);
|
|
80
|
+
const line = [escapeCmdCommand(resolved), ...args.map(a => escapeCmdArg(a, doubleEscape))].join(" ");
|
|
81
|
+
return {
|
|
82
|
+
file: env.ComSpec ?? "cmd.exe",
|
|
83
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
84
|
+
options: { windowsVerbatimArguments: true },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `sh -c <command>` analog per platform. The configured command string is passed
|
|
90
|
+
* VERBATIM in content; on win32 it gets the outer quotes `/s` requires, so
|
|
91
|
+
* `"C:\Program Files\x.exe" --json` runs as `cmd.exe /d /s /c ""C:\Program Files\x.exe" --json"`.
|
|
92
|
+
* Contract: the command is platform-native shell syntax (sh on POSIX, CMD on Windows).
|
|
93
|
+
*/
|
|
94
|
+
export function shellInvocation(
|
|
95
|
+
command: string,
|
|
96
|
+
platform: NodeJS.Platform = process.platform,
|
|
97
|
+
env: Record<string, string | undefined> = process.env,
|
|
98
|
+
): SpawnInvocation {
|
|
99
|
+
if (platform !== "win32") return { file: "sh", args: ["-c", command], options: {} };
|
|
100
|
+
return {
|
|
101
|
+
file: env.ComSpec ?? "cmd.exe",
|
|
102
|
+
args: ["/d", "/s", "/c", `"${command}"`],
|
|
103
|
+
options: { windowsVerbatimArguments: true },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -235,12 +235,16 @@ export abstract class OAuthCallbackFlow {
|
|
|
235
235
|
while (true) {
|
|
236
236
|
const result = await Promise.race([
|
|
237
237
|
callbackPromise,
|
|
238
|
-
requestManualInput()
|
|
238
|
+
requestManualInput(expectedState)
|
|
239
239
|
.then((input): CallbackResult | null => {
|
|
240
240
|
const parsed = parseCallbackInput(input);
|
|
241
241
|
if (!parsed.code) return null;
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
// Kind-aware state enforcement: url/query-shaped input is an authorization
|
|
243
|
+
// RESPONSE and must carry a matching state — missing state is rejected, not
|
|
244
|
+
// downgraded to raw. Only a syntactically raw code (same PKCE session) is
|
|
245
|
+
// exempt, so the CLI/GUI paste fallback still works.
|
|
246
|
+
if (parsed.kind !== "raw" && expectedState && parsed.state !== expectedState) return null;
|
|
247
|
+
return { code: parsed.code, state: parsed.state ?? expectedState };
|
|
244
248
|
})
|
|
245
249
|
.catch((): CallbackResult | null => null),
|
|
246
250
|
]);
|
|
@@ -255,14 +259,19 @@ export abstract class OAuthCallbackFlow {
|
|
|
255
259
|
}
|
|
256
260
|
}
|
|
257
261
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Parse a redirect URL or code string to extract code and state.
|
|
264
|
+
* `kind` records the syntactic shape so callers can enforce state on authorization
|
|
265
|
+
* responses (url/query) while exempting raw in-session codes.
|
|
266
|
+
*/
|
|
267
|
+
export function parseCallbackInput(input: string): { kind: "url" | "query" | "raw"; code?: string; state?: string } {
|
|
260
268
|
const value = input.trim();
|
|
261
|
-
if (!value) return {};
|
|
269
|
+
if (!value) return { kind: "raw" };
|
|
262
270
|
|
|
263
271
|
try {
|
|
264
272
|
const url = new URL(value);
|
|
265
273
|
return {
|
|
274
|
+
kind: "url",
|
|
266
275
|
code: url.searchParams.get("code") ?? undefined,
|
|
267
276
|
state: url.searchParams.get("state") ?? undefined,
|
|
268
277
|
};
|
|
@@ -273,6 +282,7 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
|
|
|
273
282
|
if (value.includes("code=")) {
|
|
274
283
|
const params = new URLSearchParams(value.replace(/^[?#]/, ""));
|
|
275
284
|
return {
|
|
285
|
+
kind: "query",
|
|
276
286
|
code: params.get("code") ?? undefined,
|
|
277
287
|
state: params.get("state") ?? undefined,
|
|
278
288
|
};
|
|
@@ -280,5 +290,5 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
|
|
|
280
290
|
|
|
281
291
|
// Assume raw code, possibly with state after #
|
|
282
292
|
const [code, state] = value.split("#", 2);
|
|
283
|
-
return { code, state };
|
|
293
|
+
return { kind: "raw", code, state };
|
|
284
294
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
+
import { parseCallbackInput } from "./callback-server";
|
|
2
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
3
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
5
|
import { maskEmail } from "../lib/privacy";
|
|
@@ -362,10 +363,95 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
|
|
|
362
363
|
* GUI async login: start the flow, return the auth URL EARLY (the flow keeps running in the
|
|
363
364
|
* background until the callback server captures the redirect), with a concurrency guard and an
|
|
364
365
|
* error surfaced via getLoginStatus().
|
|
366
|
+
*
|
|
367
|
+
* Manual fallback: when the browser cannot reach the loopback callback (remote GUI, SSH, blocked
|
|
368
|
+
* localhost), the GUI can POST the final redirect URL or authorization code via
|
|
369
|
+
* submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
|
|
365
370
|
*/
|
|
366
371
|
const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
367
372
|
const loginAbort = new Map<string, AbortController>();
|
|
368
373
|
|
|
374
|
+
/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
|
|
375
|
+
interface ManualCodeSlot {
|
|
376
|
+
pendingInput?: string;
|
|
377
|
+
resolve?: (value: string) => void;
|
|
378
|
+
/** Registered by the callback flow so submits can validate state synchronously. */
|
|
379
|
+
expectedState?: string;
|
|
380
|
+
}
|
|
381
|
+
const loginManual = new Map<string, ManualCodeSlot>();
|
|
382
|
+
|
|
383
|
+
function clearManualCodeSlot(provider: string): void {
|
|
384
|
+
loginManual.delete(provider);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function ensureManualCodeSlot(provider: string): ManualCodeSlot {
|
|
388
|
+
let slot = loginManual.get(provider);
|
|
389
|
+
if (!slot) {
|
|
390
|
+
slot = {};
|
|
391
|
+
loginManual.set(provider, slot);
|
|
392
|
+
}
|
|
393
|
+
return slot;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
|
|
397
|
+
function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
|
|
398
|
+
if (signal.aborted) {
|
|
399
|
+
return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
400
|
+
}
|
|
401
|
+
const slot = ensureManualCodeSlot(provider);
|
|
402
|
+
if (expectedState !== undefined) slot.expectedState = expectedState;
|
|
403
|
+
if (slot.pendingInput !== undefined) {
|
|
404
|
+
const value = slot.pendingInput;
|
|
405
|
+
slot.pendingInput = undefined;
|
|
406
|
+
return Promise.resolve(value);
|
|
407
|
+
}
|
|
408
|
+
return new Promise<string>((resolve, reject) => {
|
|
409
|
+
const onAbort = () => {
|
|
410
|
+
if (slot.resolve === resolve) slot.resolve = undefined;
|
|
411
|
+
reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
412
|
+
};
|
|
413
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
414
|
+
slot.resolve = (value: string) => {
|
|
415
|
+
signal.removeEventListener("abort", onAbort);
|
|
416
|
+
if (slot.resolve === resolve) slot.resolve = undefined;
|
|
417
|
+
resolve(value);
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Feed a pasted redirect URL or authorization code into an in-progress GUI login.
|
|
424
|
+
* Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
|
|
425
|
+
* here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
|
|
426
|
+
*/
|
|
427
|
+
export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
|
|
428
|
+
const trimmed = input.trim();
|
|
429
|
+
if (!trimmed) return { ok: false, error: "empty code" };
|
|
430
|
+
const st = loginState.get(provider);
|
|
431
|
+
if (!st || st.done) return { ok: false, error: "no login in progress" };
|
|
432
|
+
const slot = ensureManualCodeSlot(provider);
|
|
433
|
+
// Synchronous validation (validated request/ack): reject un-parseable input and
|
|
434
|
+
// authorization responses (url/query kind) whose state is missing or mismatched
|
|
435
|
+
// once the flow has registered its expected state. Raw codes stay in-session-PKCE
|
|
436
|
+
// protected. Early posts (flow not yet waiting, no expectedState) are stashed and
|
|
437
|
+
// re-validated by the callback loop.
|
|
438
|
+
const parsed = parseCallbackInput(trimmed);
|
|
439
|
+
if (!parsed.code) return { ok: false, error: "no authorization code found in input" };
|
|
440
|
+
if (parsed.kind !== "raw" && slot.expectedState !== undefined) {
|
|
441
|
+
if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
|
|
442
|
+
if (parsed.state !== slot.expectedState) return { ok: false, error: "state mismatch — paste the redirect URL from THIS login attempt" };
|
|
443
|
+
}
|
|
444
|
+
if (slot.resolve) {
|
|
445
|
+
const resolve = slot.resolve;
|
|
446
|
+
slot.resolve = undefined;
|
|
447
|
+
resolve(trimmed);
|
|
448
|
+
} else {
|
|
449
|
+
// Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
|
|
450
|
+
slot.pendingInput = trimmed;
|
|
451
|
+
}
|
|
452
|
+
return { ok: true };
|
|
453
|
+
}
|
|
454
|
+
|
|
369
455
|
export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
|
|
370
456
|
|
|
371
457
|
export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
@@ -400,6 +486,7 @@ export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean
|
|
|
400
486
|
export function clearLoginState(provider: string): void {
|
|
401
487
|
loginAbort.get(provider)?.abort("cleared");
|
|
402
488
|
loginAbort.delete(provider);
|
|
489
|
+
clearManualCodeSlot(provider);
|
|
403
490
|
loginState.delete(provider);
|
|
404
491
|
}
|
|
405
492
|
|
|
@@ -409,6 +496,7 @@ export function cancelLoginFlow(provider: string): boolean {
|
|
|
409
496
|
if (!ctrl && (!existing || existing.done)) return false;
|
|
410
497
|
ctrl?.abort("cancelled");
|
|
411
498
|
loginAbort.delete(provider);
|
|
499
|
+
clearManualCodeSlot(provider);
|
|
412
500
|
loginState.set(provider, { done: true, error: "Login cancelled" });
|
|
413
501
|
return true;
|
|
414
502
|
}
|
|
@@ -420,6 +508,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
420
508
|
if (existing && !existing.done) {
|
|
421
509
|
throw new Error(`A login for ${provider} is already in progress`);
|
|
422
510
|
}
|
|
511
|
+
clearManualCodeSlot(provider);
|
|
423
512
|
loginState.set(provider, { done: false });
|
|
424
513
|
const abort = new AbortController();
|
|
425
514
|
loginAbort.set(provider, abort);
|
|
@@ -431,12 +520,15 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
431
520
|
resolve({ url, instructions });
|
|
432
521
|
},
|
|
433
522
|
onProgress: () => {},
|
|
523
|
+
// GUI fallback when the browser cannot hit the loopback callback server.
|
|
524
|
+
onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
|
|
434
525
|
signal: abort.signal,
|
|
435
526
|
};
|
|
436
527
|
// Background: runLogin persists the credential + upserts the provider entry to disk config.
|
|
437
528
|
runLogin(provider, ctrl, opts)
|
|
438
529
|
.then(() => {
|
|
439
530
|
loginAbort.delete(provider);
|
|
531
|
+
clearManualCodeSlot(provider);
|
|
440
532
|
loginState.set(provider, { done: true });
|
|
441
533
|
// Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
|
|
442
534
|
// resolve so the GUI call returns instead of hanging.
|
|
@@ -444,6 +536,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
444
536
|
})
|
|
445
537
|
.catch((e: unknown) => {
|
|
446
538
|
loginAbort.delete(provider);
|
|
539
|
+
clearManualCodeSlot(provider);
|
|
447
540
|
const msg = e instanceof Error ? e.message : String(e);
|
|
448
541
|
loginState.set(provider, { done: true, error: msg });
|
|
449
542
|
if (!urlResolved) reject(e);
|
package/src/oauth/types.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ProviderAccountSet {
|
|
|
31
31
|
export interface OAuthController {
|
|
32
32
|
onAuth?(info: { url: string; instructions?: string }): void;
|
|
33
33
|
onProgress?(message: string): void;
|
|
34
|
-
onManualCodeInput?(): Promise<string>;
|
|
34
|
+
onManualCodeInput?(expectedState?: string): Promise<string>;
|
|
35
35
|
signal?: AbortSignal;
|
|
36
36
|
}
|
|
37
37
|
|
package/src/providers/derive.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface DerivedProviderPreset {
|
|
|
48
48
|
oauthProvider?: string;
|
|
49
49
|
dashboardUrl?: string;
|
|
50
50
|
note?: string;
|
|
51
|
+
keyOptional?: boolean;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function listRegistryEntries(): readonly ProviderRegistryEntry[] {
|
|
@@ -69,6 +70,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
69
70
|
authMode: entry.authKind === "local" ? undefined : entry.authKind,
|
|
70
71
|
...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
|
|
71
72
|
...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}),
|
|
73
|
+
...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}),
|
|
72
74
|
...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
|
|
73
75
|
...(entry.models ? { models: [...entry.models] } : {}),
|
|
74
76
|
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
|
|
@@ -193,6 +195,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
193
195
|
if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
|
|
194
196
|
if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional;
|
|
195
197
|
if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip;
|
|
198
|
+
if (!prov.headers && seed.headers) prov.headers = { ...seed.headers };
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
export function deriveFeaturedProviderIds(): string[] {
|
|
@@ -227,6 +230,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
|
|
|
227
230
|
...(entry.authKind === "oauth" ? { oauthProvider: entry.oauthId ?? entry.id } : {}),
|
|
228
231
|
...(entry.dashboardUrl ? { dashboardUrl: entry.dashboardUrl } : {}),
|
|
229
232
|
...(entry.note ? { note: entry.note } : {}),
|
|
233
|
+
...(entry.keyOptional ? { keyOptional: true } : {}),
|
|
230
234
|
};
|
|
231
235
|
}
|
|
232
236
|
|
|
@@ -23,6 +23,8 @@ export interface ProviderRegistryEntry {
|
|
|
23
23
|
allowPrivateNetworkByDefault?: boolean;
|
|
24
24
|
keyOptional?: boolean;
|
|
25
25
|
allowBaseUrlOverride?: boolean;
|
|
26
|
+
/** Static headers merged into every upstream request for this provider. */
|
|
27
|
+
staticHeaders?: Record<string, string>;
|
|
26
28
|
modelSuffixBracketStrip?: boolean;
|
|
27
29
|
featured?: boolean;
|
|
28
30
|
dashboardPreset?: boolean;
|
|
@@ -66,7 +68,7 @@ export type ProviderConfigSeed = Pick<
|
|
|
66
68
|
| "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
|
|
67
69
|
| "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
|
|
68
70
|
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
|
|
69
|
-
| "googleMode" | "project" | "location"
|
|
71
|
+
| "googleMode" | "project" | "location" | "headers"
|
|
70
72
|
>;
|
|
71
73
|
|
|
72
74
|
// Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
|
|
@@ -133,6 +135,7 @@ const THINKING_BUDGET_MODELS = [
|
|
|
133
135
|
];
|
|
134
136
|
const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"];
|
|
135
137
|
const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
|
138
|
+
const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
|
|
136
139
|
// "max" is advertised too: the wire map routes xhigh->max and max->max, so the picker
|
|
137
140
|
// should surface the max tier instead of hiding it behind xhigh.
|
|
138
141
|
const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh", "max"];
|
|
@@ -608,8 +611,41 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
608
611
|
},
|
|
609
612
|
{ id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
|
|
610
613
|
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
|
|
614
|
+
{
|
|
615
|
+
id: "opencode-free",
|
|
616
|
+
label: "OpenCode Free",
|
|
617
|
+
adapter: "openai-chat",
|
|
618
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
619
|
+
authKind: "key",
|
|
620
|
+
keyOptional: true,
|
|
621
|
+
featured: true,
|
|
622
|
+
liveModels: true,
|
|
623
|
+
note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
|
|
624
|
+
dashboardUrl: "https://opencode.ai",
|
|
625
|
+
staticHeaders: {
|
|
626
|
+
"x-opencode-client": "desktop",
|
|
627
|
+
},
|
|
628
|
+
modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
|
|
629
|
+
modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
|
|
630
|
+
preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
|
|
631
|
+
noVisionModels: OPENCODE_FREE_DEEPSEEK_MODELS,
|
|
632
|
+
},
|
|
611
633
|
{ id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
|
|
612
634
|
{ id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
|
|
635
|
+
{
|
|
636
|
+
id: "mimo-free",
|
|
637
|
+
label: "MiMo Free",
|
|
638
|
+
adapter: "mimo-free",
|
|
639
|
+
baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
|
|
640
|
+
authKind: "key",
|
|
641
|
+
keyOptional: true,
|
|
642
|
+
featured: true,
|
|
643
|
+
liveModels: true,
|
|
644
|
+
dashboardUrl: "https://xiaomimimo.com",
|
|
645
|
+
defaultModel: "mimo-auto",
|
|
646
|
+
models: ["mimo-auto"],
|
|
647
|
+
note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
|
|
648
|
+
},
|
|
613
649
|
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
614
650
|
// FREEZE 2026-07-10: /models is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
615
651
|
{ id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
|
|
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "../adapters/azure";
|
|
|
3
3
|
import { createCursorAdapter } from "../adapters/cursor";
|
|
4
4
|
import { createGoogleAdapter } from "../adapters/google";
|
|
5
5
|
import { createKiroAdapter } from "../adapters/kiro";
|
|
6
|
+
import { createMimoFreeAdapter } from "../adapters/mimo-free";
|
|
6
7
|
import { createOpenAIChatAdapter } from "../adapters/openai-chat";
|
|
7
8
|
import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
|
|
8
9
|
import type { OcxProviderConfig } from "../types";
|
|
@@ -40,6 +41,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention
|
|
|
40
41
|
return createAzureAdapter(providerConfig);
|
|
41
42
|
case "cursor":
|
|
42
43
|
return createCursorAdapter(providerConfig);
|
|
44
|
+
case "mimo-free":
|
|
45
|
+
return createMimoFreeAdapter(providerConfig);
|
|
43
46
|
default:
|
|
44
47
|
throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
|
|
45
48
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
providerHeadersConfigError,
|
|
7
7
|
} from "../config";
|
|
8
8
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
9
|
+
import { getProviderRegistryEntry } from "../providers/registry";
|
|
9
10
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
10
11
|
|
|
11
12
|
let _corsOrigin = "http://localhost:10100";
|
|
@@ -210,6 +211,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
210
211
|
"disabled",
|
|
211
212
|
"allowPrivateNetwork",
|
|
212
213
|
"authMode",
|
|
214
|
+
"keyOptional",
|
|
213
215
|
"liveModels",
|
|
214
216
|
"models",
|
|
215
217
|
"contextWindow",
|
|
@@ -227,6 +229,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
227
229
|
] as const) {
|
|
228
230
|
copyIfDefined(dto, provider, key);
|
|
229
231
|
}
|
|
232
|
+
const registryNote = getProviderRegistryEntry(name)?.note;
|
|
233
|
+
if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
|
|
230
234
|
providers[name] = dto;
|
|
231
235
|
}
|
|
232
236
|
return {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isOAuthProvider,
|
|
17
17
|
listOAuthProviders,
|
|
18
18
|
startLoginFlow,
|
|
19
|
+
submitManualLoginCode,
|
|
19
20
|
upsertOAuthProvider,
|
|
20
21
|
} from "../oauth";
|
|
21
22
|
import { removeCredential } from "../oauth/store";
|
|
@@ -28,6 +29,7 @@ import { readUsageEntries } from "../usage/log";
|
|
|
28
29
|
import { getUsageDebugLogEntries } from "../usage/debug";
|
|
29
30
|
import { parseRange, summarizeUsage } from "../usage/summary";
|
|
30
31
|
import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
|
|
32
|
+
import { getProviderRegistryEntry } from "../providers/registry";
|
|
31
33
|
import { getDebugLogEntries } from "../lib/debug-log-buffer";
|
|
32
34
|
import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
|
|
33
35
|
import {
|
|
@@ -430,7 +432,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
430
432
|
// let the (possibly new) apiKey join the pool as the active entry.
|
|
431
433
|
const existingPool = config.providers[name]?.apiKeyPool;
|
|
432
434
|
if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
|
|
433
|
-
config.providers[name] = prov;
|
|
435
|
+
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
434
436
|
if (body.setDefault) config.defaultProvider = name;
|
|
435
437
|
save(config);
|
|
436
438
|
if (prov.apiKey && prov.apiKeyPool) {
|
|
@@ -609,10 +611,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
609
611
|
let toggle = deps.toggleCodexMultiAgentV2;
|
|
610
612
|
if (!toggle) {
|
|
611
613
|
const { execFileSync } = await import("node:child_process");
|
|
614
|
+
const { codexFeaturesInvocation } = await import("../cli/v2");
|
|
612
615
|
toggle = (enabled: boolean) => {
|
|
613
|
-
const
|
|
614
|
-
execFileSync(
|
|
615
|
-
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
|
|
616
|
+
const inv = codexFeaturesInvocation(enabled ? "enable" : "disable");
|
|
617
|
+
execFileSync(inv.file, inv.args,
|
|
618
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...inv.options });
|
|
616
619
|
};
|
|
617
620
|
}
|
|
618
621
|
const result = transitionMultiAgentV2(targetFlag, toggle, {
|
|
@@ -1043,6 +1046,21 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1043
1046
|
}
|
|
1044
1047
|
}
|
|
1045
1048
|
|
|
1049
|
+
// Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
|
|
1050
|
+
// when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
|
|
1051
|
+
if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
|
|
1052
|
+
const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
|
|
1053
|
+
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1054
|
+
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1055
|
+
const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
|
|
1056
|
+
// Authorization responses are measured in hundreds of bytes; never accept the
|
|
1057
|
+
// generic management-body allowance here.
|
|
1058
|
+
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
1059
|
+
const result = submitManualLoginCode(provider, input);
|
|
1060
|
+
if (!result.ok) return jsonResponse({ error: result.error }, 409);
|
|
1061
|
+
return jsonResponse({ ok: true });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1046
1064
|
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
1047
1065
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1048
1066
|
return jsonResponse(getLoginStatus(provider));
|
|
@@ -1207,3 +1225,15 @@ export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]>
|
|
|
1207
1225
|
const { gatherRoutedModels } = await import("../codex/catalog");
|
|
1208
1226
|
return gatherRoutedModels(config);
|
|
1209
1227
|
}
|
|
1228
|
+
|
|
1229
|
+
function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
1230
|
+
const entry = getProviderRegistryEntry(name);
|
|
1231
|
+
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
1232
|
+
const headerEntries = Object.entries(provider.headers);
|
|
1233
|
+
const staticEntries = Object.entries(entry.staticHeaders);
|
|
1234
|
+
if (headerEntries.length !== staticEntries.length) return provider;
|
|
1235
|
+
const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
|
|
1236
|
+
if (!matchesRegistryStaticHeaders) return provider;
|
|
1237
|
+
const { headers: _headers, ...rest } = provider;
|
|
1238
|
+
return rest;
|
|
1239
|
+
}
|
package/src/server/relay.ts
CHANGED
|
@@ -212,10 +212,14 @@ export function responseWithDeferredRequestLog(
|
|
|
212
212
|
return response;
|
|
213
213
|
}
|
|
214
214
|
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
215
|
-
if (response.body && contentType.includes("application/json")) {
|
|
215
|
+
if (response.body && (contentType.includes("application/json") || response.status >= 400)) {
|
|
216
216
|
const finalizeJsonLog = async () => {
|
|
217
217
|
const text = await response.text();
|
|
218
|
-
|
|
218
|
+
// Non-JSON error bodies: inspect/log only a bounded prefix (the stored
|
|
219
|
+
// upstreamError is 500 chars anyway); the FULL text is still forwarded to the
|
|
220
|
+
// client below, unchanged. JSON bodies keep full inspection (usage parsing).
|
|
221
|
+
const isJson = contentType.includes("application/json");
|
|
222
|
+
inspectResponseLogJson(logCtx, isJson ? text : text.slice(0, 8192));
|
|
219
223
|
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
220
224
|
return text;
|
|
221
225
|
};
|
|
@@ -294,7 +294,10 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
|
|
|
294
294
|
logCtx.upstreamError = redactSecretString(incompleteReasonLabel(reason.trim())).slice(0, 500);
|
|
295
295
|
}
|
|
296
296
|
} catch {
|
|
297
|
-
|
|
297
|
+
const trimmed = text.trim();
|
|
298
|
+
if (trimmed) {
|
|
299
|
+
logCtx.upstreamError = redactSecretString(trimmed).slice(0, 500);
|
|
300
|
+
}
|
|
298
301
|
}
|
|
299
302
|
}
|
|
300
303
|
|