@bitkyc08/opencodex 2.7.40 → 2.7.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { formatErrorResponse } from "../../bridge";
|
|
2
|
+
import { parseRetryAfterMs } from "../../combos";
|
|
3
|
+
|
|
4
|
+
function sanitizedRetryAfter(value: string | null | undefined, now: number): string | undefined {
|
|
5
|
+
const trimmed = value?.trim();
|
|
6
|
+
if (!trimmed || trimmed.length > 128) return undefined;
|
|
7
|
+
return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
|
|
12
|
+
* Codex maps an *empty* body to the literal client string "Unknown error"
|
|
13
|
+
* (UnexpectedResponseError) — issue #452. Only empty bodies need wrapping.
|
|
14
|
+
*
|
|
15
|
+
* Non-empty bodies (including ChatGPT `{detail: ...}` account-model 400s and
|
|
16
|
+
* HTML/text errors) must keep their original bytes and headers so pool-retry
|
|
17
|
+
* activation and client diagnostics stay honest.
|
|
18
|
+
*
|
|
19
|
+
* Normalized (empty-body) responses force `Content-Type: application/json` and
|
|
20
|
+
* preserve a validated `Retry-After` so Responses clients and the chat-completions
|
|
21
|
+
* / Claude bridges that copy that header keep correct backoff.
|
|
22
|
+
*/
|
|
23
|
+
export function formatPassthroughUpstreamError(
|
|
24
|
+
status: number,
|
|
25
|
+
bodyText: string,
|
|
26
|
+
options?: {
|
|
27
|
+
statusText?: string;
|
|
28
|
+
headers?: Headers;
|
|
29
|
+
now?: number;
|
|
30
|
+
},
|
|
31
|
+
): Response {
|
|
32
|
+
const trimmed = bodyText.trim();
|
|
33
|
+
const now = options?.now ?? Date.now();
|
|
34
|
+
const retryAfter = sanitizedRetryAfter(options?.headers?.get("retry-after"), now);
|
|
35
|
+
|
|
36
|
+
if (trimmed) {
|
|
37
|
+
return new Response(bodyText, {
|
|
38
|
+
status,
|
|
39
|
+
...(options?.statusText ? { statusText: options.statusText } : {}),
|
|
40
|
+
...(options?.headers ? { headers: options.headers } : { headers: { "Content-Type": "application/json" } }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = formatErrorResponse(
|
|
45
|
+
status,
|
|
46
|
+
"upstream_error",
|
|
47
|
+
`Provider error ${status}: (empty body)`,
|
|
48
|
+
);
|
|
49
|
+
const headers = new Headers(response.headers);
|
|
50
|
+
headers.set("Content-Type", "application/json");
|
|
51
|
+
if (retryAfter !== undefined) headers.set("Retry-After", retryAfter);
|
|
52
|
+
return new Response(response.body, { status: response.status, headers });
|
|
53
|
+
}
|
package/src/server/search.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { formatErrorResponse } from "../bridge";
|
|
12
12
|
import {
|
|
13
13
|
CodexAccountCooldownError,
|
|
14
|
+
cooldownErrorResponse,
|
|
14
15
|
CodexAuthContextError,
|
|
15
16
|
CodexPoolAuthenticationError,
|
|
16
17
|
CodexThreadAffinityExpiredError,
|
|
@@ -77,7 +78,7 @@ export async function handleSearch(
|
|
|
77
78
|
logCtx.provider = formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
|
|
78
79
|
} catch (err) {
|
|
79
80
|
if (err instanceof CodexAccountCooldownError) {
|
|
80
|
-
return
|
|
81
|
+
return cooldownErrorResponse(err);
|
|
81
82
|
}
|
|
82
83
|
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
83
84
|
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
package/src/server/system-env.ts
CHANGED
|
@@ -3,8 +3,25 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
5
5
|
import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
|
|
6
|
+
import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
|
|
7
|
+
import { resolveClaudeAuthMode } from "../claude/auth-mode";
|
|
6
8
|
import type { OcxConfig } from "../types";
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Does the opencodex dummy marker belong in the system environment?
|
|
12
|
+
*
|
|
13
|
+
* Keyed on the SAME resolver `ocx claude` uses, so an auto config with no Claude auth
|
|
14
|
+
* also reaches plain `claude` launches — before this, auto-absent users got nothing
|
|
15
|
+
* from auto-connect and the feature looked broken for exactly the people it helps
|
|
16
|
+
* (devlog 260726_claude_auth_auto/035).
|
|
17
|
+
*
|
|
18
|
+
* NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx
|
|
19
|
+
* ensure`, or a settings save). `ocx claude` re-resolves live on every launch.
|
|
20
|
+
*/
|
|
21
|
+
function systemEnvMarkerMode(config: OcxConfig): "proxy" | "subscription" {
|
|
22
|
+
return resolveClaudeAuthMode(config, detectClaudeAuth(defaultAuthDetectDeps(process.env, ownAdmissionTokens(config)))).markerMode;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
// ---------------------------------------------------------------------------
|
|
9
26
|
// Shell-hook env file: written on inject, sourced by the shell hook in .zshrc.
|
|
10
27
|
// This works for ALL new shells immediately, unlike launchctl setenv which only
|
|
@@ -31,8 +48,8 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
|
|
|
31
48
|
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
|
|
32
49
|
if (config.apiKeys?.length) {
|
|
33
50
|
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
34
|
-
} else if (config
|
|
35
|
-
lines.push(conditional("ANTHROPIC_AUTH_TOKEN",
|
|
51
|
+
} else if (systemEnvMarkerMode(config) === "proxy") {
|
|
52
|
+
lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
|
|
36
53
|
}
|
|
37
54
|
// Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
|
|
38
55
|
if (modelEnv.ANTHROPIC_MODEL) {
|
|
@@ -240,11 +257,11 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
|
|
|
240
257
|
inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
241
258
|
if (config.apiKeys?.length) {
|
|
242
259
|
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
243
|
-
} else if (config
|
|
244
|
-
inject("ANTHROPIC_AUTH_TOKEN",
|
|
245
|
-
} else if (config
|
|
260
|
+
} else if (systemEnvMarkerMode(config) === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
|
|
261
|
+
inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
|
|
262
|
+
} else if (systemEnvMarkerMode(config) !== "proxy"
|
|
246
263
|
&& injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")
|
|
247
|
-
&& launchctlGetenv("ANTHROPIC_AUTH_TOKEN") ===
|
|
264
|
+
&& launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === PROXY_MARKER) {
|
|
248
265
|
// Subscription switch-back (devlog 260720_claude_authmode_persist): remove ONLY
|
|
249
266
|
// the opencodex-owned dummy token so a launchd-started Claude regains its own
|
|
250
267
|
// claude.ai OAuth. User-set tokens (not tracked in injectedKeys, or carrying a
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft SIGTERM-before-/F is NOT available on Windows (process.kill is TerminateProcess).
|
|
3
|
+
* Graceful drain is stopProxyGracefully(); this module only resets leftover TCBs after hard kill.
|
|
4
|
+
*/
|
|
5
|
+
import { dlopen, ptr, type Pointer } from "bun:ffi";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export type TcpQuad = {
|
|
9
|
+
localAddr: string;
|
|
10
|
+
localPort: number;
|
|
11
|
+
remoteAddr: string;
|
|
12
|
+
remotePort: number;
|
|
13
|
+
state: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Parse netstat -ano rows whose local address uses `port`. Exported for tests. */
|
|
17
|
+
export function parseTcpQuadsForLocalPort(output: string, port: number): TcpQuad[] {
|
|
18
|
+
const rows: TcpQuad[] = [];
|
|
19
|
+
const portSuffix = `:${port}`;
|
|
20
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
21
|
+
const line = rawLine.trim();
|
|
22
|
+
if (!/^TCP\b/i.test(line)) continue;
|
|
23
|
+
const parts = line.split(/\s+/);
|
|
24
|
+
if (parts.length < 4) continue;
|
|
25
|
+
const local = parts[1]!;
|
|
26
|
+
const remote = parts[2]!;
|
|
27
|
+
const state = parts[3]!;
|
|
28
|
+
if (!local.endsWith(portSuffix) && !local.endsWith(`]:${port}`)) continue;
|
|
29
|
+
const localAddr = stripPort(local, port);
|
|
30
|
+
const remoteParsed = splitHostPort(remote);
|
|
31
|
+
if (!localAddr || !remoteParsed) continue;
|
|
32
|
+
rows.push({
|
|
33
|
+
localAddr,
|
|
34
|
+
localPort: port,
|
|
35
|
+
remoteAddr: remoteParsed.host,
|
|
36
|
+
remotePort: remoteParsed.port,
|
|
37
|
+
state,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripPort(addr: string, port: number): string | null {
|
|
44
|
+
const suffix = `:${port}`;
|
|
45
|
+
if (addr.endsWith(suffix)) return addr.slice(0, -suffix.length).replace(/^\[|\]$/g, "") || "0.0.0.0";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function splitHostPort(addr: string): { host: string; port: number } | null {
|
|
50
|
+
if (addr === "0.0.0.0:0" || addr === "*:*" || addr === "[::]:0") {
|
|
51
|
+
return { host: "0.0.0.0", port: 0 };
|
|
52
|
+
}
|
|
53
|
+
const m = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(addr);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
return { host: (m[1] ?? m[2] ?? "0.0.0.0").replace(/^::ffff:/i, ""), port: Number(m[3]) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ipv4ToWinUint32(addr: string): number | null {
|
|
59
|
+
const host = addr.replace(/^::ffff:/i, "");
|
|
60
|
+
// Refuse bare IPv6 — SetTcpEntry is IPv4-only; coercing "::"/"::1" to 0 would
|
|
61
|
+
// miss the real TCB and can hit an unrelated IPv4 wildcard row.
|
|
62
|
+
if (isBareIpv6Address(host)) return null;
|
|
63
|
+
if (host === "0.0.0.0" || host === "*") return 0;
|
|
64
|
+
const parts = host.split(".").map(Number);
|
|
65
|
+
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
66
|
+
return (parts[0]! | (parts[1]! << 8) | (parts[2]! << 16) | (parts[3]! << 24)) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True for bare IPv6 (including :: / ::1), false for dotted IPv4 and IPv4-mapped. */
|
|
70
|
+
export function isBareIpv6Address(addr: string): boolean {
|
|
71
|
+
const host = String(addr || "").replace(/^\[|\]$/g, "").replace(/^::ffff:/i, "");
|
|
72
|
+
if (!host) return false;
|
|
73
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
|
|
74
|
+
return host.includes(":");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type WindowsTcpDropResult = {
|
|
78
|
+
/** Successful SetTcpEntry(DELETE_TCB) calls for IPv4 rows. */
|
|
79
|
+
dropped: number;
|
|
80
|
+
/** IPv6 (or unparseable) rows skipped — never coerced into IPv4 wildcards. */
|
|
81
|
+
skippedIpv6: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function htons(port: number): number {
|
|
85
|
+
return (((port & 0xff) << 8) | ((port >> 8) & 0xff)) >>> 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type SetTcpEntryFn = (row: Pointer) => number;
|
|
89
|
+
let setTcpEntryFn: SetTcpEntryFn | null | undefined;
|
|
90
|
+
|
|
91
|
+
function loadSetTcpEntry(): SetTcpEntryFn | null {
|
|
92
|
+
if (setTcpEntryFn !== undefined) return setTcpEntryFn;
|
|
93
|
+
if (process.platform !== "win32") {
|
|
94
|
+
setTcpEntryFn = null;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const lib = dlopen("iphlpapi.dll", {
|
|
99
|
+
SetTcpEntry: { args: ["ptr"], returns: "u32" },
|
|
100
|
+
});
|
|
101
|
+
setTcpEntryFn = (row: Pointer) => lib.symbols.SetTcpEntry(row) as number;
|
|
102
|
+
} catch {
|
|
103
|
+
setTcpEntryFn = null;
|
|
104
|
+
}
|
|
105
|
+
return setTcpEntryFn;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readNetstatAno(): string {
|
|
109
|
+
const netstat = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\netstat.exe`;
|
|
110
|
+
const cmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
111
|
+
try {
|
|
112
|
+
return execFileSync(cmd, ["/d", "/c", `chcp 437>nul & "${netstat}" -ano -p tcp`], {
|
|
113
|
+
encoding: "utf-8",
|
|
114
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
115
|
+
timeout: 5000,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
return execFileSync(netstat, ["-ano", "-p", "tcp"], {
|
|
120
|
+
encoding: "utf-8",
|
|
121
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
122
|
+
timeout: 4000,
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Force-delete IPv4 TCP rows bound to `localPort` via SetTcpEntry(DELETE_TCB).
|
|
130
|
+
* Does not kill foreign processes — only resets sockets so the listen port can bind again.
|
|
131
|
+
* Bare IPv6 rows (`::1`, `::`, etc.) are skipped (not coerced into IPv4 wildcards);
|
|
132
|
+
* IPv6 TCB reclamation is unsupported on this path.
|
|
133
|
+
*/
|
|
134
|
+
export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResult {
|
|
135
|
+
if (process.platform !== "win32" || !Number.isFinite(port) || port <= 0) {
|
|
136
|
+
return { dropped: 0, skippedIpv6: 0 };
|
|
137
|
+
}
|
|
138
|
+
const setTcpEntry = loadSetTcpEntry();
|
|
139
|
+
if (!setTcpEntry) return { dropped: 0, skippedIpv6: 0 };
|
|
140
|
+
|
|
141
|
+
let output = "";
|
|
142
|
+
try {
|
|
143
|
+
output = readNetstatAno();
|
|
144
|
+
} catch {
|
|
145
|
+
return { dropped: 0, skippedIpv6: 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rows = parseTcpQuadsForLocalPort(output, Math.trunc(port));
|
|
149
|
+
let dropped = 0;
|
|
150
|
+
let skippedIpv6 = 0;
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
const localDw = ipv4ToWinUint32(row.localAddr);
|
|
153
|
+
const remoteDw = ipv4ToWinUint32(row.remoteAddr);
|
|
154
|
+
if (localDw === null || remoteDw === null) {
|
|
155
|
+
if (isBareIpv6Address(row.localAddr) || isBareIpv6Address(row.remoteAddr)) {
|
|
156
|
+
skippedIpv6 += 1;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const buf = new ArrayBuffer(20);
|
|
161
|
+
const view = new DataView(buf);
|
|
162
|
+
view.setUint32(0, 12, true); // MIB_TCP_STATE_DELETE_TCB
|
|
163
|
+
view.setUint32(4, localDw, true);
|
|
164
|
+
view.setUint32(8, htons(row.localPort), true);
|
|
165
|
+
view.setUint32(12, remoteDw, true);
|
|
166
|
+
view.setUint32(16, htons(row.remotePort), true);
|
|
167
|
+
try {
|
|
168
|
+
if (setTcpEntry(ptr(buf)) === 0) dropped += 1;
|
|
169
|
+
} catch {
|
|
170
|
+
/* keep going */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { dropped, skippedIpv6 };
|
|
174
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
12
12
|
import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
|
|
13
13
|
import { loadConfig } from "./config";
|
|
14
14
|
import { restoreNativeCodex } from "./codex/inject";
|
|
15
|
+
import { stripGrokConfig } from "./grok/inject";
|
|
15
16
|
import { isWslRuntime } from "./codex/home";
|
|
16
17
|
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
|
|
17
18
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
@@ -160,13 +161,42 @@ export function serviceReinstallArgs(): string[] {
|
|
|
160
161
|
return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
|
|
166
|
+
* touch it. Distinct from "stop failed": the manager was never even contacted, which means the
|
|
167
|
+
* installed service is still live and shared state (native Codex config, the Grok fence) must be
|
|
168
|
+
* left alone — tearing it down would strip config out from under a running service.
|
|
169
|
+
*/
|
|
170
|
+
export class ServiceOwnershipError extends Error {
|
|
171
|
+
readonly code = "service-ownership-mismatch" as const;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
|
|
175
|
+
return err instanceof ServiceOwnershipError;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when no installed service exists, or the installed one belongs to THIS
|
|
180
|
+
* CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
|
|
181
|
+
* (native Codex config, the Grok fence) that a foreign service would still be relying on.
|
|
182
|
+
*/
|
|
183
|
+
export function serviceEnvironmentOwnedHere(): boolean {
|
|
184
|
+
try {
|
|
185
|
+
assertServiceEnvironmentMatchesInstall();
|
|
186
|
+
return true;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (isServiceOwnershipError(err)) return false;
|
|
189
|
+
return true; // unrelated failure: fall back to the previous behavior rather than wedging
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
163
193
|
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
164
194
|
const state = readServiceInstallState();
|
|
165
195
|
if (!state) return;
|
|
166
196
|
const expected = normalizePathForCompare(state.codexHome);
|
|
167
197
|
const actual = normalizePathForCompare(currentCodexHome());
|
|
168
198
|
if (expected !== actual) {
|
|
169
|
-
throw new
|
|
199
|
+
throw new ServiceOwnershipError(
|
|
170
200
|
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
|
|
171
201
|
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
|
|
172
202
|
);
|
|
@@ -174,7 +204,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
|
|
|
174
204
|
const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
|
|
175
205
|
const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
|
|
176
206
|
if (expectedOpenCodexHome !== actualOpenCodexHome) {
|
|
177
|
-
throw new
|
|
207
|
+
throw new ServiceOwnershipError(
|
|
178
208
|
`Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
|
|
179
209
|
"Run the service command from the same OpenCodex home so service state and secrets match.",
|
|
180
210
|
);
|
|
@@ -1148,12 +1178,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1148
1178
|
break;
|
|
1149
1179
|
case "stop":
|
|
1150
1180
|
assertServiceEnvironmentMatchesInstall();
|
|
1151
|
-
|
|
1181
|
+
// Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
|
|
1182
|
+
// (and its Windows/Linux twins) even with nothing installed.
|
|
1183
|
+
if (ops.status() !== null || isServiceInstalled()) ops.stop();
|
|
1152
1184
|
await stopTrackedProxyForServiceCommand();
|
|
1153
1185
|
{
|
|
1154
1186
|
const restore = restoreNativeCodex();
|
|
1155
1187
|
if (restore.success) console.log("✅ service stopped + native Codex restored.");
|
|
1156
1188
|
else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
|
|
1189
|
+
// The Grok fence is the other managed config this command owns. Leaving it behind
|
|
1190
|
+
// pointed grok at a dead endpoint while native Codex was already restored.
|
|
1191
|
+
const grok = stripGrokConfig();
|
|
1192
|
+
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
1193
|
+
else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
|
|
1157
1194
|
}
|
|
1158
1195
|
break;
|
|
1159
1196
|
case "status": {
|
|
@@ -1181,6 +1218,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1181
1218
|
if (!restore.success) {
|
|
1182
1219
|
console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
|
|
1183
1220
|
}
|
|
1221
|
+
const grok = stripGrokConfig();
|
|
1222
|
+
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
1223
|
+
else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
|
|
1184
1224
|
}
|
|
1185
1225
|
removeServiceInstallState();
|
|
1186
1226
|
try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
|
package/src/types.ts
CHANGED
|
@@ -308,6 +308,12 @@ export interface OcxUrlCitation {
|
|
|
308
308
|
export interface OcxUsage {
|
|
309
309
|
inputTokens: number;
|
|
310
310
|
outputTokens: number;
|
|
311
|
+
/**
|
|
312
|
+
* Absolute active-context size after the response. Stateful providers can expose this separately
|
|
313
|
+
* from their per-attempt usage. Responses serialization derives the input side from
|
|
314
|
+
* `contextTotalTokens - outputTokens` so output is never added to an absolute checkpoint twice.
|
|
315
|
+
*/
|
|
316
|
+
contextTotalTokens?: number;
|
|
311
317
|
totalTokens?: number;
|
|
312
318
|
cachedInputTokens?: number;
|
|
313
319
|
cacheReadInputTokens?: number;
|
|
@@ -356,11 +362,26 @@ export interface OcxClaudeCodeConfig {
|
|
|
356
362
|
*/
|
|
357
363
|
systemEnv?: boolean;
|
|
358
364
|
/**
|
|
359
|
-
* Auth mode for Claude Code inbound requests
|
|
360
|
-
*
|
|
361
|
-
*
|
|
365
|
+
* Auth mode for Claude Code inbound requests — a THREE-state intent.
|
|
366
|
+
*
|
|
367
|
+
* "proxy": inject the dummy ANTHROPIC_AUTH_TOKEN so Claude Code routes through the
|
|
368
|
+
* proxy without a real Anthropic key. "subscription": never inject it. UNSET means
|
|
369
|
+
* AUTO: the mode is resolved from detected Claude auth on every launch and every
|
|
370
|
+
* status read (src/claude/auth-mode.ts), so registering a Claude login switches the
|
|
371
|
+
* behaviour with no migration and no stored state.
|
|
372
|
+
*
|
|
373
|
+
* An explicit value always wins over detection and is never rewritten by the auto
|
|
374
|
+
* logic — that is what makes a manual choice stick (devlog 260726_claude_auth_auto).
|
|
375
|
+
*/
|
|
376
|
+
authMode?: "proxy" | "subscription";
|
|
377
|
+
/**
|
|
378
|
+
* ISO timestamp of the one-time authMode migration. Before auto existed, choosing
|
|
379
|
+
* "Subscription" DELETED the key, so a pre-upgrade config cannot distinguish an
|
|
380
|
+
* explicit subscription choice from "never chose". Its ABSENCE identifies a
|
|
381
|
+
* pre-upgrade block; the migration writes it once and never re-runs, so a user who
|
|
382
|
+
* later picks Auto (which deletes authMode) is not silently converted back.
|
|
362
383
|
*/
|
|
363
|
-
|
|
384
|
+
authModeMigratedAt?: string;
|
|
364
385
|
/**
|
|
365
386
|
* Context-window override for Claude Code/Desktop clients (devlog 136 B6):
|
|
366
387
|
* injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
|
|
@@ -415,6 +436,27 @@ export interface OcxClaudeCodeConfig {
|
|
|
415
436
|
webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
|
|
416
437
|
/** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
|
|
417
438
|
visionSidecar?: { backend?: "openai" | "anthropic"; model?: string };
|
|
439
|
+
/** Persisted Claude Desktop four-family routing profile. */
|
|
440
|
+
desktopProfile?: OcxClaudeDesktopProfile;
|
|
441
|
+
/** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */
|
|
442
|
+
desktopAutoApply?: boolean;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku";
|
|
446
|
+
|
|
447
|
+
export interface OcxClaudeDesktopAssignment {
|
|
448
|
+
family: OcxClaudeDesktopFamily;
|
|
449
|
+
alias: string;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface OcxClaudeDesktopProfile {
|
|
453
|
+
version: 1;
|
|
454
|
+
assignments: Record<string, OcxClaudeDesktopAssignment>;
|
|
455
|
+
defaults: Record<OcxClaudeDesktopFamily, string | null>;
|
|
456
|
+
/** SHA-256 fingerprint of the last successfully applied 3P config content. */
|
|
457
|
+
appliedFingerprint?: string;
|
|
458
|
+
/** ISO timestamp of the last successful apply. */
|
|
459
|
+
appliedAt?: string;
|
|
418
460
|
}
|
|
419
461
|
|
|
420
462
|
/** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */
|
|
@@ -448,6 +490,16 @@ export interface OcxConfig {
|
|
|
448
490
|
* Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear.
|
|
449
491
|
*/
|
|
450
492
|
subagentModels?: string[];
|
|
493
|
+
/**
|
|
494
|
+
* Priority-ordered fallback models for spawned sub-agents. When the requested
|
|
495
|
+
* model is quota-exhausted or recently failed, opencodex rewrites the child
|
|
496
|
+
* turn to the next available entry before routing.
|
|
497
|
+
*/
|
|
498
|
+
subagentModelFallback?: string[];
|
|
499
|
+
/**
|
|
500
|
+
* TTL (ms) for cached sub-agent model availability probes. Default 60_000.
|
|
501
|
+
*/
|
|
502
|
+
subagentModelFallbackPollMs?: number;
|
|
451
503
|
injectionModel?: string;
|
|
452
504
|
/**
|
|
453
505
|
* Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls
|
|
@@ -455,6 +507,16 @@ export interface OcxConfig {
|
|
|
455
507
|
* the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
|
|
456
508
|
*/
|
|
457
509
|
injectionEffort?: string;
|
|
510
|
+
/**
|
|
511
|
+
* Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty
|
|
512
|
+
* means "everything visible", which is the historical behaviour — so an existing
|
|
513
|
+
* config keeps the fence it already had.
|
|
514
|
+
*
|
|
515
|
+
* Exclusion list rather than an inclusion list on purpose: a newly added provider
|
|
516
|
+
* model should appear in Grok by default, exactly as it does today. An inclusion list
|
|
517
|
+
* would silently hide every future model behind a switch nobody knew to flip.
|
|
518
|
+
*/
|
|
519
|
+
grokExcludedModels?: string[];
|
|
458
520
|
/**
|
|
459
521
|
* When true, OpenAI-routed requests include `service_tier: "priority"` (fast inference).
|
|
460
522
|
* When false, service_tier is stripped so requests use default speed.
|
|
@@ -475,7 +537,8 @@ export interface OcxConfig {
|
|
|
475
537
|
* <multi_agent_mode> tags). When set, it replaces the built-in prompt on whichever
|
|
476
538
|
* collab surface would have fired; firing gates are unchanged. Placeholders:
|
|
477
539
|
* `{{model}}` -> injectionModel, `{{effort}}` -> injectionEffort, `{{roster}}` ->
|
|
478
|
-
* the resolved sub-agent roster block ("" when nothing resolves)
|
|
540
|
+
* the resolved sub-agent roster block ("" when nothing resolves), `{{fallback}}` ->
|
|
541
|
+
* the configured subagent model fallback guidance block ("" when unset).
|
|
479
542
|
*/
|
|
480
543
|
injectionPrompt?: string;
|
|
481
544
|
/**
|
|
@@ -875,7 +938,12 @@ export interface OcxProviderConfig {
|
|
|
875
938
|
/** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
|
|
876
939
|
preserveReasoningContentModels?: string[];
|
|
877
940
|
/**
|
|
878
|
-
* Model ids whose
|
|
941
|
+
* Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns
|
|
942
|
+
* thinking separately in `reasoning_content` / `reasoning_details` instead of visible content.
|
|
943
|
+
*/
|
|
944
|
+
reasoningSplitModels?: string[];
|
|
945
|
+
/**
|
|
946
|
+
* Model ids whose reasoning is a vendor `thinking: {type}` toggle on the
|
|
879
947
|
* chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder.
|
|
880
948
|
* The openai-chat adapter translates the mapped effort into the thinking toggle for these.
|
|
881
949
|
*/
|
package/src/update/index.ts
CHANGED
|
@@ -204,6 +204,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
204
204
|
const capturedListen = {
|
|
205
205
|
port: runtimeTrusted ? preUpdateRt.port : configPort,
|
|
206
206
|
hostname: (runtimeTrusted ? preUpdateRt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
|
|
207
|
+
...(runtimeTrusted && livePid ? { oldPid: livePid } : {}),
|
|
207
208
|
};
|
|
208
209
|
|
|
209
210
|
// Never replace package files under a live proxy: the running server dynamic-imports
|
|
@@ -281,12 +282,17 @@ export async function runUpdate(): Promise<void> {
|
|
|
281
282
|
if (serviceWasInstalled) {
|
|
282
283
|
console.log("🔁 Reinstalling the background service with the updated files...");
|
|
283
284
|
const { serviceReinstallArgs } = await import("../service");
|
|
284
|
-
const {
|
|
285
|
-
const freed = await
|
|
286
|
-
timeoutMs:
|
|
287
|
-
intervalMs:
|
|
285
|
+
const { reclaimListenPort } = await import("../server/port-reclaim");
|
|
286
|
+
const freed = await reclaimListenPort(capturedListen.port, capturedListen.hostname, {
|
|
287
|
+
timeoutMs: 30_000,
|
|
288
|
+
intervalMs: 100,
|
|
289
|
+
scanIntervalMs: 500,
|
|
290
|
+
killOcxHolders: capturedListen.oldPid != null,
|
|
291
|
+
onlyKillPids: capturedListen.oldPid != null ? [capturedListen.oldPid] : [],
|
|
288
292
|
});
|
|
289
|
-
if (!freed)
|
|
293
|
+
if (!freed) {
|
|
294
|
+
console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; reinstalling service with pinned --port ${capturedListen.port} anyway (refusing to hop).`);
|
|
295
|
+
}
|
|
290
296
|
const prevBake = process.env.OCX_BAKE_PORT;
|
|
291
297
|
process.env.OCX_BAKE_PORT = String(capturedListen.port);
|
|
292
298
|
try {
|
|
@@ -301,26 +307,31 @@ export async function runUpdate(): Promise<void> {
|
|
|
301
307
|
// On Windows, schtasks /create requires elevation. The CLI inherits the
|
|
302
308
|
// user's (non-admin) token, so the service reinstall can fail with access
|
|
303
309
|
// denied. Fall back to a direct detached proxy start so the update never
|
|
304
|
-
// leaves the user without a running proxy.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
310
|
+
// leaves the user without a running proxy — but only when the port is free.
|
|
311
|
+
if (!freed) {
|
|
312
|
+
console.warn("⚠️ Service refresh failed and the captured port is still busy; not starting on another port.");
|
|
313
|
+
console.warn(` Run 'ocx service install' as administrator, then 'ocx start --port ${capturedListen.port}'.`);
|
|
314
|
+
} else {
|
|
315
|
+
console.warn("⚠️ Service refresh failed — starting the proxy directly instead.");
|
|
316
|
+
console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
|
|
317
|
+
const env = { ...process.env };
|
|
318
|
+
delete env.OCX_SERVICE;
|
|
319
|
+
const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
|
|
320
|
+
detached: true,
|
|
321
|
+
stdio: "ignore",
|
|
322
|
+
windowsHide: true,
|
|
323
|
+
env,
|
|
324
|
+
});
|
|
325
|
+
child.unref();
|
|
326
|
+
console.log(`✅ Proxy starting on port ${capturedListen.port}.`);
|
|
327
|
+
}
|
|
317
328
|
}
|
|
318
329
|
} finally {
|
|
319
330
|
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
320
331
|
else process.env.OCX_BAKE_PORT = prevBake;
|
|
321
332
|
}
|
|
322
333
|
} else {
|
|
323
|
-
console.log(
|
|
334
|
+
console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`);
|
|
324
335
|
}
|
|
325
336
|
} else {
|
|
326
337
|
if (trayWasRunning) {
|