@bitkyc08/opencodex 2.6.18 → 2.6.19
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/gui/dist/assets/index-Barime1y.js +9 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/transport-retry.ts +5 -26
- package/src/adapters/google-http.ts +1 -16
- package/src/adapters/kiro-retry.ts +1 -25
- package/src/cli.ts +6 -0
- package/src/codex-catalog.ts +25 -2
- package/src/doctor.ts +153 -7
- package/src/oauth/index.ts +26 -1
- package/src/oauth/token-guardian.ts +200 -0
- package/src/providers/registry.ts +27 -1
- package/src/server.ts +113 -12
- package/src/types.ts +41 -0
- package/src/upstream-retry.ts +96 -0
- package/src/vision/describe.ts +10 -6
- package/src/web-search/executor.ts +10 -6
- package/src/web-search/loop.ts +10 -6
- package/gui/dist/assets/index-DbTEyo46.js +0 -9
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-Barime1y.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-DDcEW0Cm.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { CursorRunRequest, CursorServerMessage } from "./types";
|
|
2
2
|
import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
|
|
3
|
+
import { abortError, sleepWithAbort } from "../../upstream-retry";
|
|
4
|
+
|
|
5
|
+
// Compat: historical name for the shared abortable sleep, kept for external callers.
|
|
6
|
+
export { sleepWithAbort as abortAwareSleep } from "../../upstream-retry";
|
|
3
7
|
|
|
4
8
|
export const CURSOR_RETRY_ATTEMPTS = 3;
|
|
5
9
|
export const CURSOR_RETRY_BASE_MS = 250;
|
|
@@ -36,31 +40,6 @@ export function cursorRetryDelayMs(attempt: number): number {
|
|
|
36
40
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
function abortError(signal?: AbortSignal): unknown {
|
|
40
|
-
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function abortAwareSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
44
|
-
if (ms <= 0) return;
|
|
45
|
-
if (signal?.aborted) throw abortError(signal);
|
|
46
|
-
await new Promise<void>((resolve, reject) => {
|
|
47
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
48
|
-
const cleanup = () => {
|
|
49
|
-
clearTimeout(timer);
|
|
50
|
-
signal?.removeEventListener("abort", onAbort);
|
|
51
|
-
};
|
|
52
|
-
const onAbort = () => {
|
|
53
|
-
cleanup();
|
|
54
|
-
reject(abortError(signal));
|
|
55
|
-
};
|
|
56
|
-
timer = setTimeout(() => {
|
|
57
|
-
cleanup();
|
|
58
|
-
resolve();
|
|
59
|
-
}, ms);
|
|
60
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
43
|
/**
|
|
65
44
|
* A transport is safe to retry only if it explicitly reports the run request was never committed.
|
|
66
45
|
* A transport without `requestCommitted` is treated as committed (not retryable) — fail safe.
|
|
@@ -105,7 +84,7 @@ export async function runCursorTurnWithRetry(
|
|
|
105
84
|
requestUncommitted(transport) &&
|
|
106
85
|
isRetryableCursorError(err);
|
|
107
86
|
if (!canRetry) throw err;
|
|
108
|
-
await
|
|
87
|
+
await sleepWithAbort(cursorRetryDelayMs(attempt), signal);
|
|
109
88
|
} finally {
|
|
110
89
|
await transport.close?.();
|
|
111
90
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
2
2
|
import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
|
|
3
|
+
import { abortError, sleepWithAbort } from "../upstream-retry";
|
|
3
4
|
|
|
4
5
|
const GOOGLE_RETRY_ATTEMPTS = 3;
|
|
5
6
|
const GOOGLE_RETRY_BASE_MS = 250;
|
|
@@ -22,22 +23,6 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
|
|
|
22
23
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function abortError(signal?: AbortSignal): unknown {
|
|
26
|
-
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
30
|
-
if (ms <= 0) return;
|
|
31
|
-
if (signal?.aborted) throw abortError(signal);
|
|
32
|
-
await new Promise<void>((resolve, reject) => {
|
|
33
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
34
|
-
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
|
|
35
|
-
const onAbort = () => { cleanup(); reject(abortError(signal)); };
|
|
36
|
-
timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
37
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
26
|
function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
42
27
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
43
28
|
return parent ? AbortSignal.any([parent, timeout]) : timeout;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
2
2
|
import { safeKiroHttpErrorMessage } from "./kiro-errors";
|
|
3
|
+
import { abortError, sleepWithAbort } from "../upstream-retry";
|
|
3
4
|
|
|
4
5
|
const KIRO_RETRY_ATTEMPTS = 3;
|
|
5
6
|
const KIRO_RETRY_BASE_MS = 250;
|
|
@@ -26,31 +27,6 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
|
|
|
26
27
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function abortError(signal?: AbortSignal): unknown {
|
|
30
|
-
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
34
|
-
if (ms <= 0) return;
|
|
35
|
-
if (signal?.aborted) throw abortError(signal);
|
|
36
|
-
await new Promise<void>((resolve, reject) => {
|
|
37
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
38
|
-
const cleanup = () => {
|
|
39
|
-
clearTimeout(timer);
|
|
40
|
-
signal?.removeEventListener("abort", onAbort);
|
|
41
|
-
};
|
|
42
|
-
const onAbort = () => {
|
|
43
|
-
cleanup();
|
|
44
|
-
reject(abortError(signal));
|
|
45
|
-
};
|
|
46
|
-
timer = setTimeout(() => {
|
|
47
|
-
cleanup();
|
|
48
|
-
resolve();
|
|
49
|
-
}, ms);
|
|
50
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
30
|
function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
55
31
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
56
32
|
return parent ? AbortSignal.any([parent, timeout]) : timeout;
|
package/src/cli.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "./proxy-liveness";
|
|
|
27
27
|
import { stopProxy } from "./process-control";
|
|
28
28
|
import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
|
|
29
29
|
import { drainAndShutdown, startServer } from "./server";
|
|
30
|
+
import { startTokenGuardian } from "./oauth/token-guardian";
|
|
30
31
|
import { maybeShowStarPrompt } from "./star-prompt";
|
|
31
32
|
import { maybeShowUpdatePrompt } from "./update-notify";
|
|
32
33
|
import { syncModelsToCodex } from "./codex-sync";
|
|
@@ -134,10 +135,15 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
134
135
|
writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
|
|
135
136
|
writeJournal();
|
|
136
137
|
|
|
138
|
+
// Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd
|
|
139
|
+
// so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain.
|
|
140
|
+
const guardian = startTokenGuardian();
|
|
141
|
+
|
|
137
142
|
let cleaned = false;
|
|
138
143
|
const syncCleanup = () => {
|
|
139
144
|
if (cleaned) return;
|
|
140
145
|
cleaned = true;
|
|
146
|
+
try { guardian.stop(); } catch { /* best-effort */ }
|
|
141
147
|
removePid(process.pid);
|
|
142
148
|
removeRuntimePort(process.pid);
|
|
143
149
|
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
|
package/src/codex-catalog.ts
CHANGED
|
@@ -815,6 +815,30 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
815
815
|
}
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
|
|
820
|
+
* `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
|
|
821
|
+
* only those ids. This is the single choke point applied at every CATALOG emission point (on-disk
|
|
822
|
+
* sync + /v1/models); the admin `/api/models` list stays unfiltered so the picker can show the full
|
|
823
|
+
* set. Live discovery is unaffected — this only decides what ships. See issue_052.
|
|
824
|
+
*/
|
|
825
|
+
export function filterCatalogVisibleModels(
|
|
826
|
+
models: CatalogModel[],
|
|
827
|
+
config: Pick<OcxConfig, "disabledModels" | "providers">,
|
|
828
|
+
): CatalogModel[] {
|
|
829
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
830
|
+
const allowByProvider = new Map<string, Set<string>>();
|
|
831
|
+
for (const [name, prov] of Object.entries(config.providers)) {
|
|
832
|
+
const sel = prov.selectedModels;
|
|
833
|
+
if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
|
|
834
|
+
}
|
|
835
|
+
return models.filter(m => {
|
|
836
|
+
if (disabled.has(`${m.provider}/${m.id}`)) return false;
|
|
837
|
+
const allow = allowByProvider.get(m.provider);
|
|
838
|
+
return !allow || allow.has(m.id);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
818
842
|
/**
|
|
819
843
|
* Gather routed (non-forward) provider models across the config — the single source of truth for
|
|
820
844
|
* the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
|
|
@@ -983,8 +1007,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
983
1007
|
|
|
984
1008
|
// Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
|
|
985
1009
|
// by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
|
|
986
|
-
const
|
|
987
|
-
const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
|
|
1010
|
+
const enabledGo = filterCatalogVisibleModels(goModels, config);
|
|
988
1011
|
const featured = config.subagentModels ?? [];
|
|
989
1012
|
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
990
1013
|
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
|
package/src/doctor.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
13
|
-
import { expandUserPath, getConfigDir, getConfigPath } from "./config";
|
|
13
|
+
import { expandUserPath, getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "./config";
|
|
14
14
|
import { readCodexTokens } from "./codex-auth-collision";
|
|
15
15
|
|
|
16
16
|
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -78,16 +78,143 @@ function readMounts(): string | null {
|
|
|
78
78
|
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
|
|
79
79
|
|
|
80
80
|
export type ProxyEnvRow = { key: string; present: boolean };
|
|
81
|
+
export type EnvMap = Record<string, string | undefined>;
|
|
81
82
|
|
|
82
83
|
/** Report only presence/absence of proxy env vars - never the value (it may
|
|
83
84
|
* embed credentials). Checks both upper- and lower-case forms. */
|
|
84
|
-
export function collectProxyEnv(): ProxyEnvRow[] {
|
|
85
|
+
export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
|
|
85
86
|
return PROXY_KEYS.map(key => ({
|
|
86
87
|
key,
|
|
87
|
-
present: !!(
|
|
88
|
+
present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
|
|
88
89
|
}));
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
export type ConfiguredProxyDiagnostic = {
|
|
93
|
+
key: "config.proxy";
|
|
94
|
+
present: boolean;
|
|
95
|
+
configured: boolean;
|
|
96
|
+
source: "default" | "file" | "fallback";
|
|
97
|
+
detail: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function envReferenceName(value: string): string | null {
|
|
101
|
+
const braced = value.match(/^\$\{(\w+)\}$/);
|
|
102
|
+
if (braced) return braced[1]!;
|
|
103
|
+
const bare = value.match(/^\$(\w+)$/);
|
|
104
|
+
return bare ? bare[1]! : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
|
|
108
|
+
const diagnostics = readConfigDiagnostics();
|
|
109
|
+
const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
|
|
110
|
+
if (diagnostics.error) {
|
|
111
|
+
return {
|
|
112
|
+
key: "config.proxy",
|
|
113
|
+
present: false,
|
|
114
|
+
configured: false,
|
|
115
|
+
source: diagnostics.source,
|
|
116
|
+
detail: `config unreadable (${diagnostics.error})`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!rawProxy) {
|
|
120
|
+
return {
|
|
121
|
+
key: "config.proxy",
|
|
122
|
+
present: false,
|
|
123
|
+
configured: false,
|
|
124
|
+
source: diagnostics.source,
|
|
125
|
+
detail: "not configured",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const envName = envReferenceName(rawProxy);
|
|
130
|
+
const resolved = resolveEnvValue(rawProxy);
|
|
131
|
+
if (resolved?.trim()) {
|
|
132
|
+
return {
|
|
133
|
+
key: "config.proxy",
|
|
134
|
+
present: true,
|
|
135
|
+
configured: true,
|
|
136
|
+
source: diagnostics.source,
|
|
137
|
+
detail: envName ? `env reference ${envName} resolved` : "value hidden",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
key: "config.proxy",
|
|
143
|
+
present: false,
|
|
144
|
+
configured: true,
|
|
145
|
+
source: diagnostics.source,
|
|
146
|
+
detail: envName ? `env reference ${envName} is unset` : "empty after resolution",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseProcessEnvBlock(content: string): EnvMap {
|
|
151
|
+
const env: EnvMap = {};
|
|
152
|
+
for (const entry of content.split("\0")) {
|
|
153
|
+
if (!entry) continue;
|
|
154
|
+
const separator = entry.indexOf("=");
|
|
155
|
+
if (separator <= 0) continue;
|
|
156
|
+
env[entry.slice(0, separator)] = entry.slice(separator + 1);
|
|
157
|
+
}
|
|
158
|
+
return env;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type RunningProxyEnvDiagnostic =
|
|
162
|
+
| { status: "not_running"; rows: ProxyEnvRow[] }
|
|
163
|
+
| { status: "ok"; pid: number; rows: ProxyEnvRow[] }
|
|
164
|
+
| { status: "unavailable"; pid: number; reason: string; rows: ProxyEnvRow[] };
|
|
165
|
+
|
|
166
|
+
type RunningProxyEnvDeps = {
|
|
167
|
+
readPidFn?: () => number | null;
|
|
168
|
+
readEnvironFn?: (pid: number) => string | null;
|
|
169
|
+
platform?: NodeJS.Platform | string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function readProcessEnviron(pid: number): string | null {
|
|
173
|
+
try {
|
|
174
|
+
return readFileSync(`/proc/${pid}/environ`, "utf-8");
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* [Decision Log]
|
|
182
|
+
* - Purpose: Make `ocx doctor` distinguish the current shell env from the already-running proxy process env.
|
|
183
|
+
* - Alternatives: Rename the old section only; parse service-manager env for each OS; read the recorded proxy PID's env presence.
|
|
184
|
+
* - Rationale: PID env presence is the narrowest useful diagnostic on Linux/WSL, avoids secret value output, and keeps unsupported platforms explicit.
|
|
185
|
+
*/
|
|
186
|
+
export function collectRunningProxyEnv(deps: RunningProxyEnvDeps = {}): RunningProxyEnvDiagnostic {
|
|
187
|
+
const rowsWhenEmpty = () => collectProxyEnv({});
|
|
188
|
+
const pid = (deps.readPidFn ?? readPid)();
|
|
189
|
+
if (!pid) return { status: "not_running", rows: rowsWhenEmpty() };
|
|
190
|
+
|
|
191
|
+
const platform = deps.platform ?? process.platform;
|
|
192
|
+
if (platform !== "linux" && !deps.readEnvironFn) {
|
|
193
|
+
return {
|
|
194
|
+
status: "unavailable",
|
|
195
|
+
pid,
|
|
196
|
+
reason: "process env inspection is only supported on Linux",
|
|
197
|
+
rows: rowsWhenEmpty(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const content = (deps.readEnvironFn ?? readProcessEnviron)(pid);
|
|
202
|
+
if (content === null) {
|
|
203
|
+
return {
|
|
204
|
+
status: "unavailable",
|
|
205
|
+
pid,
|
|
206
|
+
reason: "could not read process environment",
|
|
207
|
+
rows: rowsWhenEmpty(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
status: "ok",
|
|
213
|
+
pid,
|
|
214
|
+
rows: collectProxyEnv(parseProcessEnvBlock(content)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
91
218
|
export type WhamProbeResult = {
|
|
92
219
|
ok: boolean;
|
|
93
220
|
status: number | null;
|
|
@@ -142,11 +269,30 @@ export async function runDoctor(): Promise<void> {
|
|
|
142
269
|
console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
|
|
143
270
|
}
|
|
144
271
|
|
|
145
|
-
|
|
146
|
-
|
|
272
|
+
const currentProxyEnv = collectProxyEnv();
|
|
273
|
+
const configuredProxy = collectConfiguredProxy();
|
|
274
|
+
const runningProxyEnv = collectRunningProxyEnv();
|
|
275
|
+
|
|
276
|
+
console.log("\nCurrent doctor process proxy env (presence only)");
|
|
277
|
+
for (const row of currentProxyEnv) {
|
|
147
278
|
console.log(` ${row.present ? "set " : "unset "} ${row.key}`);
|
|
148
279
|
}
|
|
149
280
|
|
|
281
|
+
console.log("\nConfigured proxy (value hidden)");
|
|
282
|
+
console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
|
|
283
|
+
|
|
284
|
+
console.log("\nRunning proxy process proxy env (presence only)");
|
|
285
|
+
if (runningProxyEnv.status === "not_running") {
|
|
286
|
+
console.log(" -- no running ocx proxy process found");
|
|
287
|
+
} else if (runningProxyEnv.status === "unavailable") {
|
|
288
|
+
console.log(` -- pid ${runningProxyEnv.pid}: ${runningProxyEnv.reason}`);
|
|
289
|
+
} else {
|
|
290
|
+
console.log(` ok pid ${runningProxyEnv.pid}`);
|
|
291
|
+
for (const row of runningProxyEnv.rows) {
|
|
292
|
+
console.log(` ${row.present ? "set " : "unset "} ${row.key}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
150
296
|
console.log("\nWHAM reachability");
|
|
151
297
|
const probe = await probeWham();
|
|
152
298
|
const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
|
|
@@ -156,7 +302,7 @@ export async function runDoctor(): Promise<void> {
|
|
|
156
302
|
// Hints, not fixes.
|
|
157
303
|
const hints: string[] = [];
|
|
158
304
|
const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
|
|
159
|
-
const noProxy =
|
|
305
|
+
const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
|
|
160
306
|
if (anyDrvfs) {
|
|
161
307
|
hints.push("State dir is on a Windows-mounted (/mnt) drive. Prefer the Linux home (~) under WSL for token/lock reliability.");
|
|
162
308
|
}
|
|
@@ -164,7 +310,7 @@ export async function runDoctor(): Promise<void> {
|
|
|
164
310
|
if (probe.classification === "timeout" || probe.classification === "connect_error") {
|
|
165
311
|
hints.push("WHAM probe could not reach chatgpt.com. On WSL2 this is often NAT/DNS/VPN. Quota cannot prime, so auto-switch stays on unknown scores.");
|
|
166
312
|
if (noProxy) {
|
|
167
|
-
hints.push("No
|
|
313
|
+
hints.push("No proxy is visible to this doctor process and config.proxy is unset or unresolved. If Windows uses a proxy/VPN, set config.proxy or start ocx from a shell with HTTP(S)_PROXY.");
|
|
168
314
|
}
|
|
169
315
|
}
|
|
170
316
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
-
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
2
|
+
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
3
3
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
4
|
import { maskEmail } from "../privacy";
|
|
5
5
|
import { getCredential, saveCredential } from "./store";
|
|
@@ -24,6 +24,12 @@ interface OAuthProviderDef {
|
|
|
24
24
|
/** provider entry written into config.json on first login. */
|
|
25
25
|
providerConfig: OcxProviderConfig;
|
|
26
26
|
defaultModel: string;
|
|
27
|
+
/**
|
|
28
|
+
* Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog
|
|
29
|
+
* 260703_oauth-multi-account-refresh-and-tos). A user's per-provider `config.providers[x].refreshPolicy`
|
|
30
|
+
* overrides this. Default when unset here: "lazy-only".
|
|
31
|
+
*/
|
|
32
|
+
defaultRefreshPolicy?: RefreshPolicy;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
function oauthConfig(id: string): OcxProviderConfig {
|
|
@@ -50,6 +56,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
50
56
|
refresh: refreshAnthropicToken,
|
|
51
57
|
providerConfig: oauthConfig("anthropic"),
|
|
52
58
|
defaultModel: oauthDefaultModel("anthropic"),
|
|
59
|
+
// Anthropic actively server-side-blocks subscription OAuth outside its own clients (Feb 2026).
|
|
60
|
+
// Never generate background refresh traffic for it — grade 20, highest ToS risk.
|
|
61
|
+
defaultRefreshPolicy: "disabled",
|
|
53
62
|
},
|
|
54
63
|
kimi: {
|
|
55
64
|
login: (ctrl) => loginKimi(ctrl),
|
|
@@ -87,6 +96,22 @@ export function isOAuthProvider(name: string): boolean {
|
|
|
87
96
|
return name in OAUTH_PROVIDERS;
|
|
88
97
|
}
|
|
89
98
|
|
|
99
|
+
function isRefreshPolicy(value: unknown): value is RefreshPolicy {
|
|
100
|
+
return value === "proactive" || value === "lazy-only" || value === "disabled";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The effective proactive-refresh policy for a provider: the user's per-provider
|
|
105
|
+
* `config.providers[provider].refreshPolicy` if set, else the provider def's risk-tiered default,
|
|
106
|
+
* else "lazy-only". The guardian acts only when this resolves to "proactive".
|
|
107
|
+
*/
|
|
108
|
+
export function resolveRefreshPolicy(provider: string, config: OcxConfig): RefreshPolicy {
|
|
109
|
+
const override = config.providers[provider]?.refreshPolicy;
|
|
110
|
+
if (isRefreshPolicy(override)) return override;
|
|
111
|
+
const def = OAUTH_PROVIDERS[provider];
|
|
112
|
+
return def?.defaultRefreshPolicy ?? "lazy-only";
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/** The discovered project id stored on an OAuth credential (Antigravity CCA), if any. */
|
|
91
116
|
export function getOAuthCredentialProjectId(provider: string): string | undefined {
|
|
92
117
|
return getCredential(provider)?.projectId;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Guardian — background proactive OAuth refresh.
|
|
3
|
+
*
|
|
4
|
+
* Keeps idle tokens from aging out server-side (the reported multi-account Codex-pool bug) by
|
|
5
|
+
* refreshing them BEFORE a request needs them. It is a CALLER of the existing refresh machinery —
|
|
6
|
+
* it adds no new refresh/locking logic:
|
|
7
|
+
* - single-account providers → getValidAccessToken() (in-memory dedup + persist)
|
|
8
|
+
* - multi-account Codex pool → getValidCodexToken() (file lock + generation CAS + grant fingerprint)
|
|
9
|
+
*
|
|
10
|
+
* Safety by construction: the guardian only touches a provider whose EFFECTIVE refreshPolicy is
|
|
11
|
+
* "proactive", and the global switch (config.tokenGuardian.enabled) defaults OFF, so a default
|
|
12
|
+
* install adds zero ToS-detection surface. See devlog 260703_oauth-multi-account-refresh-and-tos.
|
|
13
|
+
*/
|
|
14
|
+
import { loadConfig } from "../config";
|
|
15
|
+
import type { OcxConfig, OcxTokenGuardianConfig } from "../types";
|
|
16
|
+
import { getCredential } from "./store";
|
|
17
|
+
import { getValidAccessToken, listOAuthProviders, resolveRefreshPolicy } from "./index";
|
|
18
|
+
import {
|
|
19
|
+
getValidCodexToken,
|
|
20
|
+
listCodexAccountIds,
|
|
21
|
+
readCodexAccountRecord,
|
|
22
|
+
TokenRefreshError,
|
|
23
|
+
} from "../codex-account-store";
|
|
24
|
+
|
|
25
|
+
export interface TokenGuardianHandle {
|
|
26
|
+
stop(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GuardianSweepResult {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
refreshed: string[];
|
|
32
|
+
failed: string[];
|
|
33
|
+
skippedBackoff: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULTS = {
|
|
37
|
+
tickSeconds: 21600, // 6h — matches codex-lb's guardian cadence
|
|
38
|
+
jitterSeconds: 300,
|
|
39
|
+
concurrency: 3,
|
|
40
|
+
leadSeconds: 900,
|
|
41
|
+
failureBackoffBaseSeconds: 300,
|
|
42
|
+
failureBackoffMaxSeconds: 3600,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface BackoffEntry {
|
|
46
|
+
attempts: number;
|
|
47
|
+
retryAfterMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Module-scoped so backoff survives across sweeps within one process (keyed "oauth:<p>" / "codex:<id>").
|
|
51
|
+
const backoff = new Map<string, BackoffEntry>();
|
|
52
|
+
|
|
53
|
+
/** Test hook: clear backoff state between cases. */
|
|
54
|
+
export function __resetGuardianState(): void {
|
|
55
|
+
backoff.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function num(value: number | undefined, fallback: number, min: number): number {
|
|
59
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolved(g: OcxTokenGuardianConfig | undefined) {
|
|
63
|
+
return {
|
|
64
|
+
tickSeconds: num(g?.tickSeconds, DEFAULTS.tickSeconds, 60),
|
|
65
|
+
jitterSeconds: num(g?.jitterSeconds, DEFAULTS.jitterSeconds, 0),
|
|
66
|
+
concurrency: Math.max(1, Math.floor(num(g?.concurrency, DEFAULTS.concurrency, 1))),
|
|
67
|
+
leadSeconds: num(g?.leadSeconds, DEFAULTS.leadSeconds, 0),
|
|
68
|
+
backoffBaseSeconds: num(g?.failureBackoffBaseSeconds, DEFAULTS.failureBackoffBaseSeconds, 0),
|
|
69
|
+
backoffMaxSeconds: num(g?.failureBackoffMaxSeconds, DEFAULTS.failureBackoffMaxSeconds, 0),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inBackoff(key: string, nowMs: number): boolean {
|
|
74
|
+
const entry = backoff.get(key);
|
|
75
|
+
return entry !== undefined && entry.retryAfterMs > nowMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function recordFailure(key: string, nowMs: number, baseSeconds: number, maxSeconds: number, permanent: boolean): void {
|
|
79
|
+
const prev = backoff.get(key);
|
|
80
|
+
const attempts = (prev?.attempts ?? 0) + 1;
|
|
81
|
+
// Permanent failures (revoked/expired refresh token) wait the full ceiling — nothing but a
|
|
82
|
+
// re-login fixes them, so there is no point retrying sooner.
|
|
83
|
+
const delaySeconds = permanent
|
|
84
|
+
? maxSeconds
|
|
85
|
+
: Math.min(maxSeconds, baseSeconds * 2 ** (attempts - 1));
|
|
86
|
+
backoff.set(key, { attempts, retryAfterMs: nowMs + delaySeconds * 1000 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
|
|
90
|
+
let cursor = 0;
|
|
91
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
|
|
92
|
+
while (cursor < tasks.length) {
|
|
93
|
+
const task = tasks[cursor++];
|
|
94
|
+
if (task) await task();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
await Promise.all(workers);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One refresh sweep. Reads live config + stores; refreshes every proactive-policy credential that
|
|
102
|
+
* will expire before the next sweep (tick + lead horizon). Never throws — per-credential failures
|
|
103
|
+
* are captured into backoff and the result. Returns a redacted summary (provider names / account
|
|
104
|
+
* ids only, never tokens).
|
|
105
|
+
*/
|
|
106
|
+
export async function guardianSweep(nowMs: number = Date.now()): Promise<GuardianSweepResult> {
|
|
107
|
+
const config: OcxConfig = loadConfig();
|
|
108
|
+
const g = config.tokenGuardian;
|
|
109
|
+
const result: GuardianSweepResult = { enabled: !!g?.enabled, refreshed: [], failed: [], skippedBackoff: [] };
|
|
110
|
+
if (!g?.enabled) return result;
|
|
111
|
+
|
|
112
|
+
const opts = resolved(g);
|
|
113
|
+
const horizonMs = (opts.tickSeconds + opts.leadSeconds) * 1000;
|
|
114
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
115
|
+
|
|
116
|
+
// A) single-account OAuth providers
|
|
117
|
+
for (const provider of listOAuthProviders()) {
|
|
118
|
+
if (resolveRefreshPolicy(provider, config) !== "proactive") continue;
|
|
119
|
+
const cred = getCredential(provider);
|
|
120
|
+
if (!cred) continue;
|
|
121
|
+
if (cred.expires > nowMs + horizonMs) continue;
|
|
122
|
+
const key = `oauth:${provider}`;
|
|
123
|
+
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
124
|
+
tasks.push(async () => {
|
|
125
|
+
try {
|
|
126
|
+
await getValidAccessToken(provider);
|
|
127
|
+
backoff.delete(key);
|
|
128
|
+
result.refreshed.push(key);
|
|
129
|
+
} catch {
|
|
130
|
+
// Single-account refresh throws generic errors; treat as transient (exponential backoff).
|
|
131
|
+
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false);
|
|
132
|
+
result.failed.push(key);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// B) multi-account Codex pool (gated on the chatgpt provider's policy)
|
|
138
|
+
if (resolveRefreshPolicy("chatgpt", config) === "proactive") {
|
|
139
|
+
for (const id of listCodexAccountIds()) {
|
|
140
|
+
const record = readCodexAccountRecord(id);
|
|
141
|
+
const cred = record?.deletedAt == null ? record?.credential : undefined;
|
|
142
|
+
if (!cred) continue;
|
|
143
|
+
if (cred.expiresAt > nowMs + horizonMs) continue;
|
|
144
|
+
const key = `codex:${id}`;
|
|
145
|
+
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
146
|
+
tasks.push(async () => {
|
|
147
|
+
try {
|
|
148
|
+
await getValidCodexToken(id);
|
|
149
|
+
backoff.delete(key);
|
|
150
|
+
result.refreshed.push(key);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
|
|
153
|
+
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent);
|
|
154
|
+
result.failed.push(key);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await runWithConcurrency(tasks, opts.concurrency);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Start the background sweep loop. Returns a handle whose stop() clears the pending timer (in-flight
|
|
166
|
+
* refreshes settle on their own). Schedules recursively so each interval gets fresh jitter. The loop
|
|
167
|
+
* runs even when the guardian is disabled (each sweep is a cheap no-op) so toggling `enabled` in
|
|
168
|
+
* config takes effect on the next tick without a restart.
|
|
169
|
+
*/
|
|
170
|
+
export function startTokenGuardian(): TokenGuardianHandle {
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
173
|
+
|
|
174
|
+
const scheduleNext = () => {
|
|
175
|
+
if (stopped) return;
|
|
176
|
+
const opts = resolved(loadConfig().tokenGuardian);
|
|
177
|
+
const delayMs = (opts.tickSeconds + Math.random() * opts.jitterSeconds) * 1000;
|
|
178
|
+
timer = setTimeout(runSweep, delayMs);
|
|
179
|
+
if (typeof timer.unref === "function") timer.unref(); // never keep the process alive for a sweep
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const runSweep = () => {
|
|
183
|
+
void guardianSweep()
|
|
184
|
+
.then(r => {
|
|
185
|
+
if (r.enabled && (r.refreshed.length || r.failed.length)) {
|
|
186
|
+
console.log(`🛡️ token-guardian: refreshed ${r.refreshed.length}, failed ${r.failed.length}`);
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
.catch(err => console.log(`token-guardian sweep error: ${err instanceof Error ? err.message : String(err)}`))
|
|
190
|
+
.finally(scheduleNext);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
scheduleNext();
|
|
194
|
+
return {
|
|
195
|
+
stop() {
|
|
196
|
+
stopped = true;
|
|
197
|
+
if (timer) clearTimeout(timer);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|