@bitkyc08/opencodex 2.6.28-preview.20260707 → 2.6.30-preview.20260707

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.
@@ -12,4 +12,4 @@ Error generating stack: `+e.message+`
12
12
  -d '{
13
13
  "model": "gpt-5.4",
14
14
  "input": "Hello, world!"
15
- }'`})]})]})}var xt=`opencodex-api-token`,St=!1,Ct=null;function wt(e){try{let t=e instanceof Request?e.url:String(e);return new URL(t,window.location.href).pathname}catch{return null}}function Tt(e){let t=wt(e);return!!t&&(t.startsWith(`/api/`)||t.startsWith(`/v1/`))}function Et(){try{return sessionStorage.getItem(xt)?.trim()||null}catch{return null}}function Dt(e){try{sessionStorage.setItem(xt,e)}catch{}}function Ot(){try{sessionStorage.removeItem(xt)}catch{}}function kt(e,t,n){let r=new Headers(t?.headers??(e instanceof Request?e.headers:void 0));return r.set(`X-OpenCodex-API-Key`,n),e instanceof Request?[new Request(e,{headers:r}),t?{...t,headers:r}:void 0]:[e,{...t,headers:r}]}async function At(){return Ct||(Ct=Promise.resolve().then(()=>window.prompt(`OpenCodex API token`)?.trim()||null).finally(()=>{Ct=null}),Ct)}function jt(){if(St)return;St=!0;let e=window.fetch.bind(window);window.fetch=async(t,n)=>{if(!Tt(t))return e(t,n);let r=Et(),[i,a]=r?kt(t,n,r):[t,n],o=await e(i,a);if(o.status!==401)return o;r&&Ot();let s=await At();if(!s)return o;Dt(s);let[c,l]=kt(t,n,s),u=await e(c,l);return u.status===401&&Ot(),u}}jt();var Mt=new Set([`dashboard`,`providers`,`models`,`subagents`,`logs`,`usage`,`codex-auth`,`api`]);function Nt(){let e=location.hash.replace(/^#\/?/,``);return Mt.has(e)?e:`dashboard`}var Pt=``,Ft=`ocx-theme`,It=[{id:`dashboard`,tkey:`nav.dashboard`,Icon:w},{id:`providers`,tkey:`nav.providers`,Icon:T},{id:`models`,tkey:`nav.models`,Icon:E},{id:`subagents`,tkey:`nav.subagents`,Icon:ee},{id:`logs`,tkey:`nav.logs`,Icon:D},{id:`usage`,tkey:`nav.usage`,Icon:te},{id:`codex-auth`,tkey:`nav.codexAuth`,Icon:L},{id:`api`,tkey:`nav.api`,Icon:de}],Lt={light:ce,dark:le,system:ue},Rt={light:`theme.light`,dark:`theme.dark`,system:`theme.system`};function zt(e){if(!e||typeof e!=`object`||!(`version`in e))return null;let t=e.version;return typeof t==`string`&&t.length>0?t:null}function Bt(){let e=localStorage.getItem(Ft);return e===`light`||e===`dark`?e:`system`}function Vt(){let[e,t]=(0,_.useState)(Nt),n=e=>{location.hash=e,t(e)},[r,i]=(0,_.useState)(Bt),[a,o]=(0,_.useState)(null),{locale:s,setLocale:c}=be(),l=xe();(0,_.useEffect)(()=>{let e=()=>t(Nt());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,_.useEffect)(()=>{let e=document.documentElement;r===`system`?(e.removeAttribute(`data-theme`),localStorage.removeItem(Ft)):(e.setAttribute(`data-theme`,r),localStorage.setItem(Ft,r))},[r]),(0,_.useEffect)(()=>{let e=!1,t=async()=>{try{let t=await fetch(`${Pt}/healthz`);if(!t.ok)return;let n=zt(await t.json());!e&&n&&o(n)}catch{}};t();let n=setInterval(t,3e4);return()=>{e=!0,clearInterval(n)}},[]);let u=()=>i(e=>e===`light`?`dark`:e===`dark`?`system`:`light`),d=Lt[r],f=a??`2.6.28-preview.20260707`,p=me.find(e=>e.code===s)?.name??`English`,m=()=>{let e=me.map(e=>e.code);c(e[(e.indexOf(s)+1)%e.length])},[h,g]=(0,_.useState)(!1);return(0,S.jsxs)(`div`,{className:`app`,children:[(0,S.jsxs)(`aside`,{className:`sidebar`,children:[(0,S.jsxs)(`div`,{className:`brand`,children:[(0,S.jsx)(`span`,{className:`brand-logo`,role:`img`,"aria-label":`opencodex logo`}),(0,S.jsx)(`span`,{className:`name`,children:`opencodex`}),(0,S.jsxs)(`span`,{className:`ver`,children:[`v`,f]})]}),(0,S.jsx)(`nav`,{children:It.map(({id:t,tkey:r,Icon:i})=>(0,S.jsxs)(`button`,{className:`nav-item${e===t?` active`:``}`,"data-page":t,onClick:()=>n(t),"aria-current":e===t?`page`:void 0,children:[(0,S.jsx)(i,{}),` `,l(r)]},t))}),(0,S.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:m,"aria-label":`${l(`lang.label`)}: ${p}`,title:`${l(`lang.label`)}: ${p}`,children:[(0,S.jsx)(de,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:p})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:u,"aria-label":`${l(`theme.label`)}: ${l(Rt[r])}`,title:`${l(`theme.label`)}: ${l(Rt[r])}`,children:[(0,S.jsx)(d,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(Rt[r])})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle stop-toggle`,onClick:async()=>{if(confirm(l(`dash.stopConfirm`))){g(!0);try{await fetch(`${Pt}/api/stop`,{method:`POST`})}catch{}}},disabled:h,"aria-label":l(`dash.stop`),title:l(`dash.stop`),children:[(0,S.jsx)(F,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(h?`dash.stopping`:`dash.stop`)})]}),(0,S.jsxs)(`a`,{className:`sidebar-link`,href:`https://github.com/lidge-jun/opencodex`,target:`_blank`,rel:`noreferrer`,children:[(0,S.jsx)(P,{}),` `,l(`common.github`)]})]})]}),(0,S.jsx)(`main`,{className:`main`,children:(0,S.jsxs)(`div`,{className:`main-inner`,children:[e===`dashboard`&&(0,S.jsx)(Oe,{apiBase:Pt}),e===`providers`&&(0,S.jsx)(We,{apiBase:Pt}),e===`models`&&(0,S.jsx)(Ye,{apiBase:Pt}),e===`subagents`&&(0,S.jsx)(Xe,{apiBase:Pt}),e===`logs`&&(0,S.jsx)(ot,{apiBase:Pt}),e===`usage`&&(0,S.jsx)(pt,{apiBase:Pt}),e===`codex-auth`&&(0,S.jsx)(ht,{apiBase:Pt}),e===`api`&&(0,S.jsx)(bt,{apiBase:Pt})]})})]})}v.createRoot(document.getElementById(`root`)).render((0,S.jsx)(_.StrictMode,{children:(0,S.jsx)(ye,{children:(0,S.jsx)(Vt,{})})}));
15
+ }'`})]})]})}var xt=`opencodex-api-token`,St=!1,Ct=null;function wt(e){try{let t=e instanceof Request?e.url:String(e);return new URL(t,window.location.href).pathname}catch{return null}}function Tt(e){let t=wt(e);return!!t&&(t.startsWith(`/api/`)||t.startsWith(`/v1/`))}function Et(){try{return sessionStorage.getItem(xt)?.trim()||null}catch{return null}}function Dt(e){try{sessionStorage.setItem(xt,e)}catch{}}function Ot(){try{sessionStorage.removeItem(xt)}catch{}}function kt(e,t,n){let r=new Headers(t?.headers??(e instanceof Request?e.headers:void 0));return r.set(`X-OpenCodex-API-Key`,n),e instanceof Request?[new Request(e,{headers:r}),t?{...t,headers:r}:void 0]:[e,{...t,headers:r}]}async function At(){return Ct||(Ct=Promise.resolve().then(()=>window.prompt(`OpenCodex API token`)?.trim()||null).finally(()=>{Ct=null}),Ct)}function jt(){if(St)return;St=!0;let e=window.fetch.bind(window);window.fetch=async(t,n)=>{if(!Tt(t))return e(t,n);let r=Et(),[i,a]=r?kt(t,n,r):[t,n],o=await e(i,a);if(o.status!==401)return o;r&&Ot();let s=await At();if(!s)return o;Dt(s);let[c,l]=kt(t,n,s),u=await e(c,l);return u.status===401&&Ot(),u}}jt();var Mt=new Set([`dashboard`,`providers`,`models`,`subagents`,`logs`,`usage`,`codex-auth`,`api`]);function Nt(){let e=location.hash.replace(/^#\/?/,``);return Mt.has(e)?e:`dashboard`}var Pt=``,Ft=`ocx-theme`,It=[{id:`dashboard`,tkey:`nav.dashboard`,Icon:w},{id:`providers`,tkey:`nav.providers`,Icon:T},{id:`models`,tkey:`nav.models`,Icon:E},{id:`subagents`,tkey:`nav.subagents`,Icon:ee},{id:`logs`,tkey:`nav.logs`,Icon:D},{id:`usage`,tkey:`nav.usage`,Icon:te},{id:`codex-auth`,tkey:`nav.codexAuth`,Icon:L},{id:`api`,tkey:`nav.api`,Icon:de}],Lt={light:ce,dark:le,system:ue},Rt={light:`theme.light`,dark:`theme.dark`,system:`theme.system`};function zt(e){if(!e||typeof e!=`object`||!(`version`in e))return null;let t=e.version;return typeof t==`string`&&t.length>0?t:null}function Bt(){let e=localStorage.getItem(Ft);return e===`light`||e===`dark`?e:`system`}function Vt(){let[e,t]=(0,_.useState)(Nt),n=e=>{location.hash=e,t(e)},[r,i]=(0,_.useState)(Bt),[a,o]=(0,_.useState)(null),{locale:s,setLocale:c}=be(),l=xe();(0,_.useEffect)(()=>{let e=()=>t(Nt());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,_.useEffect)(()=>{let e=document.documentElement;r===`system`?(e.removeAttribute(`data-theme`),localStorage.removeItem(Ft)):(e.setAttribute(`data-theme`,r),localStorage.setItem(Ft,r))},[r]),(0,_.useEffect)(()=>{let e=!1,t=async()=>{try{let t=await fetch(`${Pt}/healthz`);if(!t.ok)return;let n=zt(await t.json());!e&&n&&o(n)}catch{}};t();let n=setInterval(t,3e4);return()=>{e=!0,clearInterval(n)}},[]);let u=()=>i(e=>e===`light`?`dark`:e===`dark`?`system`:`light`),d=Lt[r],f=a??`2.6.30-preview.20260707`,p=me.find(e=>e.code===s)?.name??`English`,m=()=>{let e=me.map(e=>e.code);c(e[(e.indexOf(s)+1)%e.length])},[h,g]=(0,_.useState)(!1);return(0,S.jsxs)(`div`,{className:`app`,children:[(0,S.jsxs)(`aside`,{className:`sidebar`,children:[(0,S.jsxs)(`div`,{className:`brand`,children:[(0,S.jsx)(`span`,{className:`brand-logo`,role:`img`,"aria-label":`opencodex logo`}),(0,S.jsx)(`span`,{className:`name`,children:`opencodex`}),(0,S.jsxs)(`span`,{className:`ver`,children:[`v`,f]})]}),(0,S.jsx)(`nav`,{children:It.map(({id:t,tkey:r,Icon:i})=>(0,S.jsxs)(`button`,{className:`nav-item${e===t?` active`:``}`,"data-page":t,onClick:()=>n(t),"aria-current":e===t?`page`:void 0,children:[(0,S.jsx)(i,{}),` `,l(r)]},t))}),(0,S.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:m,"aria-label":`${l(`lang.label`)}: ${p}`,title:`${l(`lang.label`)}: ${p}`,children:[(0,S.jsx)(de,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:p})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:u,"aria-label":`${l(`theme.label`)}: ${l(Rt[r])}`,title:`${l(`theme.label`)}: ${l(Rt[r])}`,children:[(0,S.jsx)(d,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(Rt[r])})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle stop-toggle`,onClick:async()=>{if(confirm(l(`dash.stopConfirm`))){g(!0);try{await fetch(`${Pt}/api/stop`,{method:`POST`})}catch{}}},disabled:h,"aria-label":l(`dash.stop`),title:l(`dash.stop`),children:[(0,S.jsx)(F,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(h?`dash.stopping`:`dash.stop`)})]}),(0,S.jsxs)(`a`,{className:`sidebar-link`,href:`https://github.com/lidge-jun/opencodex`,target:`_blank`,rel:`noreferrer`,children:[(0,S.jsx)(P,{}),` `,l(`common.github`)]})]})]}),(0,S.jsx)(`main`,{className:`main`,children:(0,S.jsxs)(`div`,{className:`main-inner`,children:[e===`dashboard`&&(0,S.jsx)(Oe,{apiBase:Pt}),e===`providers`&&(0,S.jsx)(We,{apiBase:Pt}),e===`models`&&(0,S.jsx)(Ye,{apiBase:Pt}),e===`subagents`&&(0,S.jsx)(Xe,{apiBase:Pt}),e===`logs`&&(0,S.jsx)(ot,{apiBase:Pt}),e===`usage`&&(0,S.jsx)(pt,{apiBase:Pt}),e===`codex-auth`&&(0,S.jsx)(ht,{apiBase:Pt}),e===`api`&&(0,S.jsx)(bt,{apiBase:Pt})]})})]})}v.createRoot(document.getElementById(`root`)).render((0,S.jsx)(_.StrictMode,{children:(0,S.jsx)(ye,{children:(0,S.jsx)(Vt,{})})}));
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CkV5xFA8.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-D6YMHYXe.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-ByGC8-Bm.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.28-preview.20260707",
3
+ "version": "2.6.30-preview.20260707",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
package/src/cli/doctor.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*.
9
9
  */
10
10
  import { existsSync, readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "../config";
13
14
  import { readCodexTokens } from "../codex/auth-collision";
14
- import { resolveCodexHomeDir as resolveCodexHomeDirImpl } from "../codex/home";
15
+ import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
16
+ import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
15
17
  import { countPendingOpencodexHistory } from "../codex/history-provider";
16
18
  export { resolveCodexHomeDir } from "../codex/home";
17
19
 
@@ -70,6 +72,70 @@ function readMounts(): string | null {
70
72
  }
71
73
  }
72
74
 
75
+ export type WslDualInstallDiagnostic = {
76
+ wsl: boolean;
77
+ automountRoot: string;
78
+ effectiveCodexHome: string;
79
+ effectiveIsWindowsMount: boolean;
80
+ linuxCodexConfigured: boolean;
81
+ windowsCodexHomes: string[];
82
+ dualInstall: boolean;
83
+ interopCodexOnPath: string | null;
84
+ };
85
+
86
+ type WslDualInstallDeps = CodexHomeDeps & {
87
+ pathValue?: string;
88
+ effectiveCodexHome?: string;
89
+ };
90
+
91
+ /**
92
+ * WSL + Windows dual-install visibility: which `.codex` home each side owns,
93
+ * whether the effective home sits on a Windows mount, and whether the `codex`
94
+ * on PATH is actually the Windows launcher reached through drive interop.
95
+ * Read-only; hints are printed by runDoctor, never applied.
96
+ */
97
+ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualInstallDiagnostic {
98
+ const wsl = isWslRuntime(deps);
99
+ const effectiveCodexHome = deps.effectiveCodexHome ?? resolveCodexHomeDirImpl(deps);
100
+ if (!wsl) {
101
+ return {
102
+ wsl: false,
103
+ automountRoot: "/mnt",
104
+ effectiveCodexHome,
105
+ effectiveIsWindowsMount: false,
106
+ linuxCodexConfigured: false,
107
+ windowsCodexHomes: [],
108
+ dualInstall: false,
109
+ interopCodexOnPath: null,
110
+ };
111
+ }
112
+ const automountRoot = wslAutomountRoot(deps);
113
+ const exists = deps.existsSync ?? existsSync;
114
+ const home = (deps.homedir ?? homedir)();
115
+ const linuxCodexConfigured = !!home && exists(join(home, ".codex", "config.toml"));
116
+ const windowsCodexHomes = listWslWindowsCodexHomes(deps);
117
+ const onPath = findCodexOnPath({
118
+ pathValue: deps.pathValue ?? process.env.PATH,
119
+ wsl: false, // scan everything; classify interop ourselves
120
+ posixPaths: true, // WSL PATH entries are POSIX regardless of the host running doctor
121
+ automountRoot,
122
+ // When a fake fs is injected (tests), the real lstat/readFile would miss its
123
+ // synthetic paths; treat every injected hit as a plain non-shim file.
124
+ ...(deps.existsSync ? { exists: deps.existsSync, isShimFile: () => false, isDirectory: () => false } : {}),
125
+ });
126
+ const interopCodexOnPath = onPath && isWindowsInteropDir(onPath, automountRoot) ? onPath : null;
127
+ return {
128
+ wsl,
129
+ automountRoot,
130
+ effectiveCodexHome,
131
+ effectiveIsWindowsMount: isWindowsInteropDir(effectiveCodexHome, automountRoot),
132
+ linuxCodexConfigured,
133
+ windowsCodexHomes,
134
+ dualInstall: linuxCodexConfigured && windowsCodexHomes.length > 0,
135
+ interopCodexOnPath,
136
+ };
137
+ }
138
+
73
139
  const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
74
140
 
75
141
  export type ProxyEnvRow = { key: string; present: boolean };
@@ -307,6 +373,21 @@ export async function runDoctor(): Promise<void> {
307
373
  console.log(` -- ${pending.pendingRows} thread(s) still tagged opencodex, ${pending.backupEntries} backup manifest entr${pending.backupEntries === 1 ? "y" : "ies"}`);
308
374
  }
309
375
 
376
+ const dual = collectWslDualInstall();
377
+ if (dual.wsl) {
378
+ console.log("\nWSL Codex installs");
379
+ console.log(` ${dual.linuxCodexConfigured ? "ok " : "-- "} Linux ~/.codex/config.toml`);
380
+ if (dual.windowsCodexHomes.length > 0) {
381
+ for (const winHome of dual.windowsCodexHomes) console.log(` ok Windows ${winHome}`);
382
+ } else {
383
+ console.log(" -- no Windows-profile .codex detected under /mnt/c/Users");
384
+ }
385
+ console.log(` effective CODEX_HOME: ${dual.effectiveCodexHome}${dual.effectiveIsWindowsMount ? " (Windows mount)" : ""}`);
386
+ if (dual.interopCodexOnPath) {
387
+ console.log(` -- codex on PATH is the Windows launcher via interop: ${dual.interopCodexOnPath}`);
388
+ }
389
+ }
390
+
310
391
  // Hints, not fixes.
311
392
  const hints: string[] = [];
312
393
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
@@ -325,6 +406,13 @@ export async function runDoctor(): Promise<void> {
325
406
  if (pending.failed || pending.pendingRows > 0 || pending.backupEntries > 0) {
326
407
  hints.push("Legacy chat threads are still tagged opencodex (or the DB was locked). The running proxy retries the migration automatically; to force it now, close the Codex app and run 'ocx sync'.");
327
408
  }
409
+ if (dual.dualInstall && !dual.effectiveIsWindowsMount) {
410
+ hints.push(`Codex is installed on BOTH WSL and Windows. Each side keeps its own ~/.codex (logins, config, catalog are separate); ocx here manages the Linux one. To share a single home, set CODEX_HOME=${dual.windowsCodexHomes[0] ?? `${dual.automountRoot}/c/Users/<you>/.codex`} in WSL (drvfs file locking is less reliable).`);
411
+ hints.push("localhost is one-way in WSL2 NAT mode: Windows-side codex reaches this WSL proxy via localhost (localhostForwarding, on by default), but a Windows-side proxy is NOT reachable from WSL via localhost — use networkingMode=mirrored in .wslconfig for both directions.");
412
+ }
413
+ if (dual.interopCodexOnPath) {
414
+ hints.push("The `codex` found on PATH is the Windows launcher reached through WSL interop; ocx will not shim it (a WSL shim breaks Windows invocations). Install codex inside WSL (npm i -g @openai/codex) or run 'ocx ensure' from Windows.");
415
+ }
328
416
  if (hints.length > 0) {
329
417
  console.log("\nHints");
330
418
  for (const h of hints) console.log(` - ${h}`);
package/src/codex/home.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { join, posix, resolve } from "node:path";
4
4
  import { expandUserPath } from "../config";
5
5
 
6
6
  export type CodexHomeDeps = {
@@ -10,18 +10,64 @@ export type CodexHomeDeps = {
10
10
  procVersion?: string | null;
11
11
  homedir?: () => string;
12
12
  usersRoot?: string;
13
+ /** Raw /etc/wsl.conf content override (tests); null means "no file". */
14
+ wslConf?: string | null;
13
15
  existsSync?: (path: string) => boolean;
14
16
  readdirSync?: (path: string) => string[];
15
17
  statSync?: typeof statSync;
16
18
  realpathSync?: (path: string) => string;
17
19
  };
18
20
 
19
- function windowsUserProfileToWslPath(value: string | undefined): string | null {
21
+ function windowsUserProfileToWslPath(value: string | undefined, automountRoot = DEFAULT_WSL_AUTOMOUNT_ROOT): string | null {
20
22
  if (!value) return null;
21
23
  const normalized = value.replaceAll("\\", "/");
22
24
  const match = normalized.match(/^([A-Za-z]):\/Users\/([^/]+)$/);
23
25
  if (!match) return null;
24
- return `/mnt/${match[1]!.toLowerCase()}/Users/${match[2]}`;
26
+ const root = normalizeAutomountRoot(automountRoot);
27
+ return `${root === "/" ? "" : root}/${match[1]!.toLowerCase()}/Users/${match[2]}`;
28
+ }
29
+
30
+ const DEFAULT_WSL_AUTOMOUNT_ROOT = "/mnt";
31
+
32
+ function normalizeAutomountRoot(value: string): string {
33
+ const trimmed = value.replace(/\/+$/, "");
34
+ return trimmed === "" ? "/" : trimmed;
35
+ }
36
+
37
+ function readWslConf(): string | null {
38
+ try {
39
+ return readFileSync("/etc/wsl.conf", "utf8");
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Windows drive mount root inside WSL: `[automount] root` from /etc/wsl.conf,
47
+ * default `/mnt` (https://learn.microsoft.com/en-us/windows/wsl/wsl-config).
48
+ * Returns a path without a trailing slash (or `/` itself).
49
+ */
50
+ export function wslAutomountRoot(deps: CodexHomeDeps = {}): string {
51
+ const content = deps.wslConf !== undefined ? deps.wslConf : readWslConf();
52
+ if (!content) return DEFAULT_WSL_AUTOMOUNT_ROOT;
53
+ let section = "";
54
+ for (const rawLine of content.split("\n")) {
55
+ const line = rawLine.replace(/[#;].*$/, "").trim();
56
+ if (!line) continue;
57
+ const sect = line.match(/^\[(.+)\]$/);
58
+ if (sect) {
59
+ section = sect[1]!.trim().toLowerCase();
60
+ continue;
61
+ }
62
+ if (section !== "automount") continue;
63
+ const kv = line.match(/^root\s*=\s*(.+)$/i);
64
+ if (kv) {
65
+ const value = kv[1]!.trim().replace(/^["']|["']$/g, "");
66
+ if (!value.startsWith("/")) return DEFAULT_WSL_AUTOMOUNT_ROOT;
67
+ return normalizeAutomountRoot(value);
68
+ }
69
+ }
70
+ return DEFAULT_WSL_AUTOMOUNT_ROOT;
25
71
  }
26
72
 
27
73
  function readProcVersion(): string | null {
@@ -40,23 +86,24 @@ export function isWslRuntime(deps: CodexHomeDeps = {}): boolean {
40
86
  return /microsoft|wsl/i.test(version);
41
87
  }
42
88
 
43
- export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null {
44
- if (!isWslRuntime(deps)) return null;
89
+ /** All Windows-profile `.codex` homes (with config.toml) visible from WSL, resolved real paths. */
90
+ export function listWslWindowsCodexHomes(deps: CodexHomeDeps = {}): string[] {
91
+ if (!isWslRuntime(deps)) return [];
45
92
  const exists = deps.existsSync ?? existsSync;
46
93
  const stat = deps.statSync ?? statSync;
47
94
  const readdir = deps.readdirSync ?? readdirSync;
48
95
  const realpath = deps.realpathSync ?? realpathSync.native;
49
- const env = deps.env ?? process.env;
50
- const usersRoot = deps.usersRoot ?? "/mnt/c/Users";
51
- if (!exists(usersRoot)) return null;
96
+ const automountRoot = wslAutomountRoot(deps);
97
+ // WSL mount paths are POSIX by definition; keep separators stable on any host.
98
+ const usersRoot = deps.usersRoot ?? posix.join(automountRoot, "c", "Users");
99
+ if (!exists(usersRoot)) return [];
52
100
 
53
- const explicitProfile = windowsUserProfileToWslPath(env.USERPROFILE);
54
101
  const candidates = [];
55
102
  try {
56
103
  for (const user of readdir(usersRoot)) {
57
104
  if (user === "Default" || user === "Default User" || user === "Public" || user === "All Users") continue;
58
- const home = join(usersRoot, user, ".codex");
59
- const config = join(home, "config.toml");
105
+ const home = posix.join(usersRoot, user, ".codex");
106
+ const config = posix.join(home, "config.toml");
60
107
  if (!exists(config)) continue;
61
108
  try {
62
109
  if (stat(home).isDirectory()) candidates.push(realpath(home));
@@ -65,11 +112,19 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null
65
112
  }
66
113
  }
67
114
  } catch {
68
- return null;
115
+ return [];
69
116
  }
117
+ return candidates;
118
+ }
119
+
120
+ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null {
121
+ const env = deps.env ?? process.env;
122
+ const candidates = listWslWindowsCodexHomes(deps);
123
+ if (candidates.length === 0) return null;
70
124
 
125
+ const explicitProfile = windowsUserProfileToWslPath(env.USERPROFILE, wslAutomountRoot(deps));
71
126
  if (explicitProfile) {
72
- const explicitHome = join(explicitProfile, ".codex");
127
+ const explicitHome = posix.join(explicitProfile, ".codex");
73
128
  const match = candidates.find(candidate => candidate === explicitHome || candidate.endsWith(`/${explicitProfile.split("/").pop()}/.codex`));
74
129
  if (match) return match;
75
130
  }
package/src/codex/shim.ts CHANGED
@@ -1,12 +1,17 @@
1
- import { delimiter, dirname, extname, join } from "node:path";
1
+ import { delimiter, dirname, extname, join, posix } from "node:path";
2
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { getConfigDir } from "../config";
4
4
  import { durableBunPath } from "../lib/bun-runtime";
5
5
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
6
6
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
7
+ import { isWslRuntime, wslAutomountRoot } from "./home";
7
8
 
8
9
  const SHIM_MARKER = "opencodex codex autostart shim";
9
10
  let lastShimDiscoveryError: string | null = null;
11
+ /** Last human-readable reason discovery returned null (exposed for doctor/tests). */
12
+ export function lastCodexDiscoveryError(): string | null {
13
+ return lastShimDiscoveryError;
14
+ }
10
15
  const CODEX_INTERNAL_COMMANDS = [
11
16
  "app-server",
12
17
  "archive",
@@ -66,18 +71,77 @@ function isShim(path: string): boolean {
66
71
  }
67
72
  }
68
73
 
69
- function findCodexOnPath(): string | null {
70
- for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
71
- for (const name of commandNames("codex")) {
72
- const path = join(dir, name);
73
- if (!existsSync(path) || isShim(path)) continue;
74
- try {
75
- if (!lstatSync(path).isDirectory()) return path;
76
- } catch {
77
- continue;
74
+ /**
75
+ * A PATH entry that reaches Windows through WSL drive interop
76
+ * (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
77
+ * /etc/wsl.conf [automount] root).
78
+ */
79
+ export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
80
+ const root = automountRoot.replace(/\/+$/, "");
81
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
83
+ }
84
+
85
+ export type CodexPathScanDeps = {
86
+ pathValue?: string;
87
+ wsl?: boolean;
88
+ /** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
89
+ posixPaths?: boolean;
90
+ automountRoot?: string;
91
+ exists?: (path: string) => boolean;
92
+ isShimFile?: (path: string) => boolean;
93
+ isDirectory?: (path: string) => boolean;
94
+ };
95
+
96
+ function realIsDirectory(path: string): boolean {
97
+ try {
98
+ return lstatSync(path).isDirectory();
99
+ } catch {
100
+ return true; // unreadable -> treat as unusable
101
+ }
102
+ }
103
+
104
+ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
105
+ lastShimDiscoveryError = null;
106
+ const exists = deps.exists ?? existsSync;
107
+ const shimFile = deps.isShimFile ?? isShim;
108
+ const isDir = deps.isDirectory ?? realIsDirectory;
109
+ const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
110
+ const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
111
+ const joinPath = usePosix ? posix.join : join;
112
+ const pathSep = usePosix ? ":" : delimiter;
113
+ const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
114
+ // Windows npm prefixes ship codex.exe/codex.cmd next to the extensionless sh launcher.
115
+ const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
116
+ let skippedInterop: string | null = null;
117
+
118
+ for (const dir of (deps.pathValue ?? process.env.PATH ?? "").split(pathSep).filter(Boolean)) {
119
+ if (wsl && isWindowsInteropDir(dir, automountRoot)) {
120
+ // A Windows-side codex reached through WSL PATH interop: a Unix shim written
121
+ // here would embed WSL-only paths and break every Windows-side invocation.
122
+ if (!skippedInterop) {
123
+ for (const name of interopNames) {
124
+ const path = joinPath(dir, name);
125
+ if (exists(path) && !shimFile(path) && !isDir(path)) { skippedInterop = path; break; }
126
+ }
78
127
  }
128
+ continue;
129
+ }
130
+ // Interop dirs carry Windows launcher names even when the scan is not skipping them.
131
+ const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex");
132
+ for (const name of names) {
133
+ const path = joinPath(dir, name);
134
+ if (!exists(path) || shimFile(path)) continue;
135
+ if (!isDir(path)) return path;
79
136
  }
80
137
  }
138
+
139
+ if (skippedInterop) {
140
+ lastShimDiscoveryError =
141
+ `Found a Windows codex at ${skippedInterop} via WSL PATH interop, but no Linux-side codex. ` +
142
+ "Refusing to shim a Windows launcher from WSL (a WSL shim breaks Windows invocations). " +
143
+ "Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.";
144
+ }
81
145
  return null;
82
146
  }
83
147
 
@@ -4,7 +4,8 @@
4
4
  * Direct WebCrypto + REST implementation (no `google-auth-library`). Sources, in priority order:
5
5
  * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT
6
6
  * exchange) or `type: "authorized_user"` (refresh-token exchange).
7
- * 2. `~/.config/gcloud/application_default_credentials.json` (user ADC; authorized_user flow).
7
+ * 2. gcloud user ADC (`CLOUDSDK_CONFIG`, else `%APPDATA%\gcloud` on Windows, else
8
+ * `~/.config/gcloud`) `application_default_credentials.json` (authorized_user flow).
8
9
  * 3. GCE / Cloud Run metadata server.
9
10
  *
10
11
  * Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` (default 60s)
@@ -65,8 +66,18 @@ function getRefreshSkewMs(): number {
65
66
  return Number.isFinite(raw) && raw > 0 ? raw : 60_000;
66
67
  }
67
68
 
69
+ /** gcloud config dir, matching the SDK's own resolution order. */
70
+ function gcloudConfigDir(): string {
71
+ const explicit = process.env.CLOUDSDK_CONFIG?.trim();
72
+ if (explicit) return explicit;
73
+ if (process.platform === "win32" && process.env.APPDATA) {
74
+ return path.join(process.env.APPDATA, "gcloud");
75
+ }
76
+ return path.join(os.homedir(), ".config", "gcloud");
77
+ }
78
+
68
79
  function userAdcPath(): string {
69
- return path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json");
80
+ return path.join(gcloudConfigDir(), "application_default_credentials.json");
70
81
  }
71
82
 
72
83
  /**
@@ -137,8 +137,8 @@ export async function loginAnthropic(
137
137
  } else if (importLocal === "only") {
138
138
  throw new Error(
139
139
  process.platform === "darwin"
140
- ? "No Claude Code token found in the keychain. Run 'ocx login anthropic' for browser OAuth."
141
- : "Claude Code auto-import is macOS-only. Run 'ocx login anthropic' for browser OAuth.",
140
+ ? "No Claude Code token found (Keychain or ~/.claude/.credentials.json). Run 'ocx login anthropic' for browser OAuth."
141
+ : "No Claude Code token found (~/.claude/.credentials.json). Run 'ocx login anthropic' for browser OAuth.",
142
142
  );
143
143
  }
144
144
  }
@@ -39,8 +39,14 @@ export function detectGrokCliToken(): OAuthCredentials | null {
39
39
  }
40
40
  }
41
41
 
42
- /** Read the Claude Code OAuth credential from macOS Keychain. Windows/Linux: use `ocx login`. */
43
- function readClaudeSecureStorage(): string | null {
42
+ /** Claude Code config dir: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
43
+ function claudeConfigDir(): string {
44
+ const explicit = process.env.CLAUDE_CONFIG_DIR?.trim();
45
+ return explicit ? explicit : join(homedir(), ".claude");
46
+ }
47
+
48
+ /** Read the Claude Code OAuth credential from the macOS Keychain (darwin only). */
49
+ function readClaudeKeychain(): string | null {
44
50
  if (process.platform !== "darwin") return null;
45
51
  try {
46
52
  return execSync(`security find-generic-password -s "${CLAUDE_KEYCHAIN_SERVICE}" -w`, {
@@ -51,9 +57,28 @@ function readClaudeSecureStorage(): string | null {
51
57
  }
52
58
  }
53
59
 
54
- export function detectClaudeCodeToken(): OAuthCredentials | null {
55
- const raw = readClaudeSecureStorage();
56
- if (!raw) return null;
60
+ /**
61
+ * Read the Claude Code credential file (`<config-dir>/.credentials.json`).
62
+ * Claude Code writes this on Linux/Windows (and on macOS when the Keychain is
63
+ * unavailable); it carries the same `claudeAiOauth` payload as the Keychain item.
64
+ * Exported for tests.
65
+ */
66
+ export function readClaudeCredentialsFile(): string | null {
67
+ const path = join(claudeConfigDir(), ".credentials.json");
68
+ try {
69
+ if (!existsSync(path)) return null;
70
+ return readFileSync(path, "utf8").trim();
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Keychain first on macOS, then the cross-platform credentials file. */
77
+ function readClaudeSecureStorage(): string | null {
78
+ return readClaudeKeychain() ?? readClaudeCredentialsFile();
79
+ }
80
+
81
+ export function parseClaudeOauthPayload(raw: string): OAuthCredentials | null {
57
82
  try {
58
83
  const data = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string; expiresAt?: number } };
59
84
  const o = data.claudeAiOauth;
@@ -63,3 +88,9 @@ export function detectClaudeCodeToken(): OAuthCredentials | null {
63
88
  return null;
64
89
  }
65
90
  }
91
+
92
+ export function detectClaudeCodeToken(): OAuthCredentials | null {
93
+ const raw = readClaudeSecureStorage();
94
+ if (!raw) return null;
95
+ return parseClaudeOauthPayload(raw);
96
+ }
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 { isWslRuntime } from "./codex/home";
15
16
  import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
16
17
  import { isProcessAlive, stopProxy } from "./lib/process-control";
17
18
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
@@ -572,6 +573,9 @@ function platformOps(): ServiceOps | null {
572
573
  }
573
574
  if (!isSystemd() && !existsSync(unitPath())) {
574
575
  console.error("systemd not found. Run 'ocx start' under your process supervisor.");
576
+ if (isWslRuntime()) {
577
+ console.error("WSL detected: enable systemd by adding [boot] systemd=true to /etc/wsl.conf, then run 'wsl --shutdown' from Windows and reopen the distro (WSL 0.67.6+).");
578
+ }
575
579
  process.exit(1);
576
580
  }
577
581
  return { install: installSystemd, start: startSystemd, stop: stopSystemd, status: statusSystemd, uninstall: uninstallSystemd };