@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Detects whether this process was started by a coding agent rather than typed
3
+ * by a person.
4
+ *
5
+ * Agent harnesses run `ocx` on the user's behalf and answer prompts from their
6
+ * own logic, which means a consent question would be decided by the agent
7
+ * instead of the account owner. Prompts that act on the user's identity check
8
+ * this and defer instead: they stay silent so the question reaches the human on
9
+ * a later hand-typed run.
10
+ *
11
+ * Detection is env-var based and deliberately conservative — a false positive
12
+ * only postpones a prompt, while a false negative would let an agent answer for
13
+ * the user.
14
+ */
15
+
16
+ /** Env vars set by agent harnesses and CI runners inside the shell they spawn. */
17
+ const AGENT_ENV_VARS = [
18
+ "CLAUDECODE",
19
+ "CLAUDE_CODE_ENTRYPOINT",
20
+ "CODEX_THREAD_ID",
21
+ "CODEX_SHELL",
22
+ "CODEX_CI",
23
+ "CURSOR_TRACE_ID",
24
+ "CURSOR_SESSION_TOKEN",
25
+ "AIDER_CHAT",
26
+ "REPL_ID",
27
+ "CI",
28
+ "GITHUB_ACTIONS",
29
+ ] as const;
30
+
31
+ /**
32
+ * True when an agent or automated runner is driving this process. Reads the
33
+ * environment on every call so tests and long-lived processes see current state.
34
+ */
35
+ export function isAgentDriven(env: NodeJS.ProcessEnv = process.env): boolean {
36
+ return AGENT_ENV_VARS.some(name => (env[name] ?? "").trim() !== "");
37
+ }
@@ -0,0 +1,24 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ type GatherRoutedModels = (config: OcxConfig) => Promise<unknown>;
4
+
5
+ export type CatalogPrewarmDeps = {
6
+ loadConfig?: () => OcxConfig;
7
+ importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>;
8
+ };
9
+
10
+ /**
11
+ * After the listen port is bound, kick off live provider discovery so the first
12
+ * GUI /v1/models and syncModelsToCodex share one gather flight instead of racing
13
+ * duplicate upstream /models fetches.
14
+ */
15
+ export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void {
16
+ void Promise.resolve()
17
+ .then(async () => {
18
+ const load = deps.loadConfig ?? (await import("../config")).loadConfig;
19
+ const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog"));
20
+ return gatherRoutedModels(load());
21
+ })
22
+ .catch(() => {});
23
+ }
24
+
package/src/cli/claude.ts CHANGED
@@ -14,6 +14,7 @@ import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
14
14
  import { commandInvocation } from "../lib/win-exec";
15
15
  import { findLiveProxy } from "../server/proxy-liveness";
16
16
  import type { OcxConfig } from "../types";
17
+ import { configuredAdminToken } from "../lib/admin-secrets";
17
18
  import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
18
19
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
19
20
 
@@ -47,6 +48,27 @@ export function buildClaudeEnv(
47
48
  // leaving the child with no token at all (audit R2-1). It is opencodex state, never
48
49
  // user auth, so dropping it unconditionally is safe.
49
50
  if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
51
+ // Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
52
+ // project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
53
+ // moment either token slot is populated, so an ambient project file silently moved a
54
+ // subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
55
+ // runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
56
+ // before Bun started; anything populated now but absent then came from the working
57
+ // directory, not from the user. A genuine shell export is still honored, which keeps
58
+ // auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
59
+ // (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
60
+ // change nothing rather than guess — an EMPTY marker is different: the launcher ran
61
+ // and saw no pre-existing slots.
62
+ const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
63
+ if (preBunSlots !== undefined) {
64
+ const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
65
+ for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
66
+ const value = env[name];
67
+ if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
68
+ }
69
+ }
70
+ // Never forward the seam itself to Claude Code.
71
+ delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
50
72
  const setDefault = (name: string, value: string | undefined) => {
51
73
  if (value === undefined || value.length === 0) return;
52
74
  if (env[name] !== undefined && env[name] !== "") return; // user wins
@@ -75,15 +97,19 @@ export function buildClaudeEnv(
75
97
  if ((config.apiKeys?.length ?? 0) > 0) {
76
98
  setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
77
99
  }
78
- // Detection reads the SAME base env this launch will use, so the resolver and the
79
- // spawned process cannot disagree. Injected deps are spread FIRST and `env` bound
80
- // LAST, and the injection type excludes `env`, so a test fake cannot break that.
81
- // `ownTokens` is bound last for the same reason: it is config-derived, and a fake
82
- // that replaced it could make our own admission key look like user auth.
100
+ // Detection reads the SANITIZED launch env the exact object spawned below so the
101
+ // resolver and the spawned process cannot disagree. It deliberately does NOT read the
102
+ // raw base: the provenance strip above already removed dotenv-only credentials, and
103
+ // letting a value the child never receives decide the marker left an auto-mode user
104
+ // with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
105
+ // are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
106
+ // test fake cannot break that. `ownTokens` is bound last for the same reason: it is
107
+ // config-derived, and a fake that replaced it could make our own admission key look
108
+ // like user auth.
83
109
  const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
84
- ...defaultAuthDetectDeps(base as NodeJS.ProcessEnv),
110
+ ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
85
111
  ...(deps.authDetect ?? {}),
86
- env: () => base as NodeJS.ProcessEnv,
112
+ env: () => env as NodeJS.ProcessEnv,
87
113
  ownTokens: ownAdmissionTokens(config),
88
114
  }));
89
115
  if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
@@ -149,14 +175,13 @@ export function buildClaudeEnv(
149
175
 
150
176
  /**
151
177
  * Context-window map from the RUNNING proxy's management API (warm TTL cache; the
152
- * daemon registers every selector form — audit R3#1). 3s bound + auth header
153
- * (OPENCODEX_API_AUTH_TOKEN first, config key fallback — audit R4#1). Failure → {}
178
+ * daemon registers every selector form — audit R3#1). 3s bound + management auth header.
154
179
  * (no [1m] marking, conservative).
155
180
  */
156
181
  export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise<Record<string, number>> {
157
182
  try {
158
183
  const headers = new Headers();
159
- const token = process.env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key;
184
+ const token = configuredAdminToken();
160
185
  if (token) headers.set("x-opencodex-api-key", token);
161
186
  const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
162
187
  headers,
package/src/cli/doctor.ts CHANGED
@@ -11,9 +11,11 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config";
14
+ import { findLiveProxy } from "../server/proxy-liveness";
14
15
  import { gracefulStopHost } from "../lib/process-control";
15
16
  import { maskAccountId } from "../lib/privacy";
16
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
17
+ import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env";
18
+ import { configuredAdminToken } from "../lib/admin-secrets";
17
19
  import { readCodexTokens } from "../codex/auth-collision";
18
20
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
19
21
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
@@ -285,17 +287,15 @@ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualIns
285
287
  };
286
288
  }
287
289
 
288
- const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
289
-
290
290
  export type ProxyEnvRow = { key: string; present: boolean };
291
291
  export type EnvMap = Record<string, string | undefined>;
292
292
 
293
293
  /** Report only presence/absence of proxy env vars - never the value (it may
294
294
  * embed credentials). Checks both upper- and lower-case forms. */
295
295
  export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
296
- return PROXY_KEYS.map(key => ({
296
+ return PROXY_ENV_KEYS.map(key => ({
297
297
  key,
298
- present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
298
+ present: proxyEnvPresent(key, env),
299
299
  }));
300
300
  }
301
301
 
@@ -307,13 +307,47 @@ export type ConfiguredProxyDiagnostic = {
307
307
  detail: string;
308
308
  };
309
309
 
310
- function envReferenceName(value: string): string | null {
310
+ export function envReferenceName(value: string): string | null {
311
311
  const braced = value.match(/^\$\{(\w+)\}$/);
312
312
  if (braced) return braced[1]!;
313
313
  const bare = value.match(/^\$(\w+)$/);
314
314
  return bare ? bare[1]! : null;
315
315
  }
316
316
 
317
+ export type ProviderApiKeyDiagnostic = {
318
+ provider: string;
319
+ envName: string;
320
+ detail: string;
321
+ };
322
+
323
+ /** Warn when a key-auth provider's apiKey env reference resolves empty in this process. */
324
+ export function collectProviderApiKeyDiagnostics(
325
+ providers: Record<string, { authMode?: string; apiKey?: string }> = readConfigDiagnostics().config.providers ?? {},
326
+ env: EnvMap = process.env,
327
+ ): ProviderApiKeyDiagnostic[] {
328
+ const resolveInEnv = (value: string): string | undefined => {
329
+ const name = envReferenceName(value);
330
+ if (!name) return value;
331
+ return env[name];
332
+ };
333
+ const rows: ProviderApiKeyDiagnostic[] = [];
334
+ for (const [provider, config] of Object.entries(providers)) {
335
+ if (config.authMode !== "key") continue;
336
+ const raw = typeof config.apiKey === "string" ? config.apiKey.trim() : "";
337
+ if (!raw) continue;
338
+ const envName = envReferenceName(raw);
339
+ if (!envName) continue;
340
+ const resolved = resolveInEnv(raw);
341
+ if (resolved?.trim()) continue;
342
+ rows.push({
343
+ provider,
344
+ envName,
345
+ detail: `provider ${provider}: env reference ${envName} is unset or empty in this process`,
346
+ });
347
+ }
348
+ return rows;
349
+ }
350
+
317
351
  export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
318
352
  const diagnostics = readConfigDiagnostics();
319
353
  const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
@@ -571,7 +605,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
571
605
  const lines: string[] = [];
572
606
  lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
573
607
  if (report.status === "unauthorized") {
574
- lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_API_AUTH_TOKEN to match the service");
608
+ lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_ADMIN_AUTH_TOKEN to match the service");
575
609
  return lines;
576
610
  }
577
611
  if (report.status === "unreachable") {
@@ -718,9 +752,21 @@ export async function runDoctor(args: string[] = []): Promise<void> {
718
752
  }
719
753
  }
720
754
 
755
+ // #618: identity-verified liveness first so pid-file absence does not hide a live service.
756
+ // Reuse the diagnostics config already loaded above so doctor stays read-only on malformed JSON.
757
+ const live = await findLiveProxy({
758
+ configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }),
759
+ });
760
+ const livePid = live ? live.pid : readPid();
761
+ const liveRuntime = live
762
+ ? { pid: live.pid ?? 0, port: live.port, hostname: live.hostname }
763
+ : (livePid ? readRuntimePort(livePid) : null);
764
+
721
765
  const currentProxyEnv = collectProxyEnv();
722
766
  const configuredProxy = collectConfiguredProxy();
723
- const runningProxyEnv = collectRunningProxyEnv();
767
+ const runningProxyEnv = collectRunningProxyEnv({
768
+ readPidFn: () => (live ? live.pid : readPid()),
769
+ });
724
770
 
725
771
  console.log("\nCurrent doctor process proxy env (presence only)");
726
772
  for (const row of currentProxyEnv) {
@@ -730,6 +776,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
730
776
  console.log("\nConfigured proxy (value hidden)");
731
777
  console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
732
778
 
779
+ const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers);
780
+ console.log("\nProvider API keys (value hidden)");
781
+ if (providerApiKeys.length === 0) {
782
+ console.log(" ok no empty env-referenced provider keys detected in this process");
783
+ } else {
784
+ for (const row of providerApiKeys) {
785
+ console.log(` !! ${row.detail}`);
786
+ }
787
+ }
788
+
733
789
  console.log("\nRunning proxy process proxy env (presence only)");
734
790
  if (runningProxyEnv.status === "not_running") {
735
791
  console.log(" -- no running ocx proxy process found");
@@ -742,21 +798,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
742
798
  }
743
799
  }
744
800
 
745
- // #314: service-process memory/runtime identity via the authed management
746
- // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record —
747
- // readRuntimePort alone can serve a stale file pointing at a foreign port.
748
- // Hoisted out of the block below: the Hints section reuses the same liveness pair
749
- // for the proxy-down restart hint.
750
- const livePid = readPid();
751
- const liveRuntime = livePid ? readRuntimePort(livePid) : null;
752
801
  console.log("\nMemory / runtime");
753
802
  {
754
803
  const runtime = liveRuntime;
755
- if (!runtime) {
804
+ if (!runtime || !live) {
756
805
  console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
757
806
  console.log(" -- no running ocx proxy found (no live pid/runtime record)");
758
807
  } else {
759
- const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
808
+ const token = configuredAdminToken();
760
809
  const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token);
761
810
  for (const line of formatServiceMemoryLines(report)) console.log(line);
762
811
  }
@@ -815,11 +864,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
815
864
  // Hints, not fixes.
816
865
  const hints: string[] = [];
817
866
  const proxyDown = proxyDownRestartHint({
818
- proxyRunning: Boolean(livePid && liveRuntime),
819
- port: doctorConfig.port ?? 10100,
867
+ proxyRunning: Boolean(live),
868
+ port: live?.port ?? doctorConfig.port ?? 10100,
820
869
  serviceViable: startup.serviceViable,
821
870
  });
822
871
  if (proxyDown) hints.push(proxyDown);
872
+ for (const row of providerApiKeys) {
873
+ hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
874
+ }
823
875
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
824
876
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
825
877
  if (!startup.rebootSafe) {
package/src/cli/help.ts CHANGED
@@ -30,12 +30,18 @@ const helpEntries: Record<string, HelpEntry> = {
30
30
  uninstall: {
31
31
  usage: "ocx uninstall",
32
32
  summary: "Remove service/shim/config and restore native Codex.",
33
- details: ["Alias: ocx remove"],
33
+ details: [
34
+ "Alias: ocx remove",
35
+ "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
36
+ ],
34
37
  },
35
38
  remove: {
36
39
  usage: "ocx remove",
37
40
  summary: "Remove service/shim/config and restore native Codex.",
38
- details: ["Alias of: ocx uninstall"],
41
+ details: [
42
+ "Alias of: ocx uninstall",
43
+ "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
44
+ ],
39
45
  },
40
46
  service: {
41
47
  usage: "ocx service [install|start|stop|status|uninstall|remove]",
@@ -60,8 +66,22 @@ const helpEntries: Record<string, HelpEntry> = {
60
66
  ],
61
67
  },
62
68
  ensure: { usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
63
- sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
64
- "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
69
+ sync: {
70
+ usage: "ocx sync [--restart-codex]",
71
+ summary: "Fetch provider models and inject them into Codex config.",
72
+ details: [
73
+ "After writing the catalog, warns if long-lived Codex app-server processes are still running.",
74
+ "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
75
+ ],
76
+ },
77
+ "sync-cache": {
78
+ usage: "ocx sync-cache [--restart-codex]",
79
+ summary: "Refresh Codex's model cache from the active catalog.",
80
+ details: [
81
+ "Warns when Codex app-server processes still hold an in-memory model list.",
82
+ "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
83
+ ],
84
+ },
65
85
  status: { usage: "ocx status", summary: "Check proxy server status." },
66
86
  doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
67
87
  debug: {
@@ -184,6 +204,20 @@ const helpEntries: Record<string, HelpEntry> = {
184
204
  "Claude Code settings: ocx claude config <status|set> ...",
185
205
  ],
186
206
  },
207
+ opencode: {
208
+ usage: "ocx opencode [opencode args...]",
209
+ summary: "Launch opencode wired to the proxy (runtime provider config).",
210
+ details: [
211
+ "Ensures the proxy is running, then execs `opencode` with the generated `provider.opencodex`",
212
+ "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any",
213
+ "existing inline config in the environment is preserved and only `provider.opencodex` is",
214
+ "overwritten for this launch.",
215
+ "Global/project opencode.json may be read to warn about an existing provider.opencodex",
216
+ "override; on-disk files are never modified.",
217
+ "Routed models appear in the model picker as opencodex/<provider>/<model>.",
218
+ "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.",
219
+ ],
220
+ },
187
221
  restart: {
188
222
  usage: "ocx restart",
189
223
  summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.",
@@ -232,8 +266,9 @@ Usage:
232
266
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove)
233
267
  ocx tray <sub> Windows status tray (install|start|stop|status|uninstall)
234
268
  ocx ensure Ensure the proxy is running and Codex config/cache are current
235
- ocx sync Fetch models from providers and inject into Codex config
236
- ocx sync-cache Refresh Codex's model cache from the active catalog
269
+ ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
270
+ ocx sync-cache [--restart-codex]
271
+ Refresh Codex's model cache from the active catalog
237
272
  ocx status Check proxy server status
238
273
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
239
274
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
@@ -256,6 +291,7 @@ Usage:
256
291
  ocx config <sub> Validated configuration show/get/set/import/export
257
292
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
258
293
  ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile
294
+ ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config)
259
295
  ocx help [command] Show help
260
296
  ocx --version | -v Print version
261
297
 
package/src/cli/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn } from "node:child_process";
3
- import { rmSync } from "node:fs";
4
3
  import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
5
4
  import { stripGrokConfig } from "../grok/inject";
6
5
  import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
@@ -31,7 +30,7 @@ import { stopProxy } from "../lib/process-control";
31
30
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
32
31
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
33
32
  import { startupHealthSummary } from "../codex/autostart-health";
34
- import { drainAndShutdown, startServer } from "../server";
33
+ import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
35
34
  import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
36
35
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
37
36
  import { installShellHook, uninstallShellHook } from "../server/system-env";
@@ -39,10 +38,12 @@ import { startTokenGuardian } from "../oauth/token-guardian";
39
38
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
40
39
  import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
41
40
  import { maybeShowStarPrompt } from "./star-prompt";
41
+ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
42
42
  import { maybeShowUpdatePrompt } from "../update/notify";
43
43
  import { syncModelsToCodex } from "../codex/sync";
44
44
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
45
45
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
46
+ import { removeOwnedConfigState } from "../lib/config-ownership";
46
47
 
47
48
  const args = process.argv.slice(2);
48
49
  const command = args[0];
@@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) {
190
191
  for (let attempt = 0; ; attempt++) {
191
192
  try {
192
193
  server = startServer(port);
194
+ // Prewarm the live provider model cache as soon as the port is bound so the
195
+ // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
196
+ // instead of racing duplicate upstream /models fetches.
197
+ scheduleCatalogPrewarm();
193
198
  break;
194
199
  } catch (err) {
195
200
  if (!isAddrInUse(err) || attempt >= 2) throw err;
@@ -227,24 +232,40 @@ async function handleStart(options: { block?: boolean } = {}) {
227
232
  let historyGuardian: ReturnType<typeof startHistoryMigrationGuardian> | undefined;
228
233
 
229
234
  let cleaned = false;
235
+ let cleanupSucceeded = true;
230
236
  const syncCleanup = () => {
231
- if (cleaned) return;
237
+ if (cleaned) return cleanupSucceeded;
232
238
  cleaned = true;
233
239
  try { guardian.stop(); } catch { /* best-effort */ }
234
240
  try { historyGuardian?.stop(); } catch { /* best-effort */ }
235
- try { revertSystemEnv(); } catch { /* best-effort */ }
241
+ // Dashboard drain-and-restart (#563) must not tear down injection: the replacement
242
+ // process expects Codex/Grok/env fences to still be in place.
243
+ const recycling = isRecyclingForExit();
244
+ if (!recycling) {
245
+ try { revertSystemEnv(); } catch { /* best-effort */ }
246
+ }
236
247
  removePid(process.pid);
237
248
  removeRuntimePort(process.pid);
238
- if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
239
- try { restoreNativeCodex(); } catch { /* best-effort restore */ }
249
+ if (!recycling && !process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
250
+ try {
251
+ const restored = restoreNativeCodex();
252
+ if (!restored.success) {
253
+ cleanupSucceeded = false;
254
+ console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`);
255
+ }
256
+ } catch (error) {
257
+ cleanupSucceeded = false;
258
+ console.error(`⚠️ Native Codex restore failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
259
+ }
240
260
  }
241
261
  // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the
242
262
  // Grok fence is shared state we must not remove — that service keeps running and would be
243
263
  // left pointing nowhere. This guard also covers signal-driven exits, which is the path that
244
264
  // would otherwise bypass handleStop's gate entirely.
245
- if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
265
+ if (!recycling && !process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
246
266
  try { stripGrokConfig(); } catch { /* best-effort restore */ }
247
267
  }
268
+ return cleanupSucceeded;
248
269
  };
249
270
 
250
271
  let shuttingDown = false;
@@ -269,8 +290,8 @@ async function handleStart(options: { block?: boolean } = {}) {
269
290
  try {
270
291
  await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
271
292
  } finally {
272
- syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
273
- process.exit(0);
293
+ const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
294
+ process.exit(restored ? 0 : 1);
274
295
  }
275
296
  })();
276
297
  };
@@ -288,7 +309,7 @@ async function handleStart(options: { block?: boolean } = {}) {
288
309
  // Auto-install .zshrc hook (idempotent — skips if already present).
289
310
  installShellHook();
290
311
 
291
- await maybeShowStarPrompt(); // once-only [y/N] GitHub-star prompt on first interactive start
312
+ await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
292
313
  await syncModelsToCodex(port).catch(() => {});
293
314
  if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
294
315
  historyGuardian = startHistoryMigrationGuardian();
@@ -499,7 +520,11 @@ async function handleStop() {
499
520
  }
500
521
  if (!ownershipBlocked) {
501
522
  const r = restoreNativeCodex();
502
- console.log(`↩️ ${r.message}`);
523
+ if (r.success) console.log(`↩️ ${r.message}`);
524
+ else {
525
+ stopFailed = true;
526
+ console.error(`⚠️ ${r.message}`);
527
+ }
503
528
  }
504
529
  // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
505
530
  // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
@@ -588,7 +613,13 @@ async function handleUninstall() {
588
613
 
589
614
  if (failures.length === 0) {
590
615
  await runStep("opencodex config removed", () => {
591
- rmSync(getConfigDir(), { recursive: true, force: true });
616
+ const result = removeOwnedConfigState(getConfigDir());
617
+ if (result.status === "absent") return false;
618
+ if (result.status === "removed") return true;
619
+ const residual = result.residualPaths.length > 0
620
+ ? ` Residual path(s): ${result.residualPaths.join(", ")}`
621
+ : "";
622
+ throw new Error(`${result.status} uninstall: ${result.reason ?? "config state was not removed"}.${residual}`);
592
623
  });
593
624
  } else {
594
625
  console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");
@@ -716,19 +747,40 @@ switch (command) {
716
747
  console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
717
748
  process.exit(1);
718
749
  }
719
- await syncModelsToCodex(live.port);
750
+ const synced = await syncModelsToCodex(live.port);
751
+ if (!synced.ok) {
752
+ process.exitCode = 1;
753
+ console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.");
754
+ break;
755
+ }
720
756
  const target = collectOrcaCodexHomeDiagnostic();
721
757
  console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
722
758
  break;
723
759
  }
724
- const r = restoreNativeCodex();
725
- console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`);
760
+ let r: { success: boolean; message: string };
761
+ try {
762
+ r = restoreNativeCodex();
763
+ } catch (err) {
764
+ r = { success: false, message: err instanceof Error ? err.message : String(err) };
765
+ }
766
+ if (r.success) console.log(`✅ ${r.message}`);
767
+ else {
768
+ console.error(`⚠️ ${r.message}`);
769
+ process.exitCode = 1;
770
+ }
726
771
  try {
727
772
  const g = stripGrokConfig();
728
773
  if (g.changed) console.log(`✅ ${g.message}`);
729
- else if (!g.ok) console.error(`⚠️ ${g.message}`);
774
+ else if (!g.ok) {
775
+ console.error(`⚠️ ${g.message}`);
776
+ process.exitCode = 1;
777
+ }
730
778
  } catch { /* best-effort */ }
731
- console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
779
+ if (r.success) {
780
+ console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
781
+ } else {
782
+ console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
783
+ }
732
784
  break;
733
785
  }
734
786
  case "recover-history":
@@ -767,7 +819,20 @@ switch (command) {
767
819
  break;
768
820
  }
769
821
  case "sync": {
770
- await syncModelsToCodex((await findLiveProxy())?.port);
822
+ const restartCodex = args.slice(1).includes("--restart-codex");
823
+ const synced = await syncModelsToCodex((await findLiveProxy())?.port);
824
+ if (!synced.ok) {
825
+ process.exitCode = 1;
826
+ console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
827
+ }
828
+ // Only warn/restart when a catalog or models_cache write actually happened. This is
829
+ // deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig,
830
+ // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is
831
+ // exactly when a long-lived app-server is holding the stale list.
832
+ if (synced.catalogWritten || synced.cacheSynced) {
833
+ const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
834
+ afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
835
+ }
771
836
  break;
772
837
  }
773
838
  case "v2": {
@@ -776,8 +841,13 @@ switch (command) {
776
841
  break;
777
842
  }
778
843
  case "sync-cache": {
844
+ const restartCodex = args.slice(1).includes("--restart-codex");
779
845
  const { invalidateCodexModelsCache } = await import("../codex/catalog");
780
- invalidateCodexModelsCache();
846
+ // Only warn/restart when models_cache was actually rewritten from a readable catalog.
847
+ if (invalidateCodexModelsCache()) {
848
+ const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
849
+ afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
850
+ }
781
851
  break;
782
852
  }
783
853
  case "gui": {
@@ -1005,6 +1075,10 @@ switch (command) {
1005
1075
  break;
1006
1076
  }
1007
1077
  process.exit(await cmdClaude(args.slice(1)));
1078
+ }
1079
+ case "opencode": {
1080
+ const { cmdOpencode } = await import("./opencode");
1081
+ process.exit(await cmdOpencode(args.slice(1)));
1008
1082
  }
1009
1083
  case "help":
1010
1084
  case "--help":