@bitkyc08/opencodex 2.0.2 → 2.1.3

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/src/cli.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { execFileSync, spawn } from "node:child_process";
3
+ import { rmSync } from "node:fs";
3
4
  import { restoreNativeCodex } from "./codex-inject";
4
- import { loadConfig, readPid, removePid, writePid } from "./config";
5
- import { serviceCommand, stopServiceIfInstalled } from "./service";
5
+ import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
6
+ import { findAvailablePort } from "./ports";
7
+ import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
6
8
  import { startServer } from "./server";
7
9
  import { maybeShowStarPrompt } from "./star-prompt";
8
10
 
@@ -17,9 +19,12 @@ Usage:
17
19
  ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
18
20
  ocx stop Stop the proxy AND restore native Codex (plain codex works again)
19
21
  ocx restore Restore native Codex without stopping (alias: eject)
22
+ ocx uninstall Remove service/shim/config and restore native Codex
20
23
  ocx service <sub> Run as a background service (install|start|stop|status|uninstall)
21
24
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall)
25
+ ocx ensure Ensure the proxy is running and Codex config/cache are current
22
26
  ocx sync Fetch models from providers and inject into Codex config
27
+ ocx sync-cache Refresh Codex's model cache from the active catalog
23
28
  ocx status Check proxy server status
24
29
  ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
25
30
  ocx logout <provider> Remove a stored OAuth login
@@ -55,23 +60,74 @@ async function syncModelsToCodex(port?: number) {
55
60
  return result;
56
61
  }
57
62
 
58
- async function handleStart(options: { block?: boolean } = {}) {
59
- const existingPid = readPid();
60
- if (existingPid) {
61
- console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
63
+ function parsePortOption(): number | undefined {
64
+ const portIdx = args.indexOf("--port");
65
+ if (portIdx === -1) return undefined;
66
+ const value = args[portIdx + 1];
67
+ const port = value ? parseInt(value, 10) : NaN;
68
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
69
+ console.error("Invalid port number");
62
70
  process.exit(1);
63
71
  }
72
+ return port;
73
+ }
64
74
 
65
- let port: number | undefined;
66
- const portIdx = args.indexOf("--port");
67
- if (portIdx !== -1 && args[portIdx + 1]) {
68
- port = parseInt(args[portIdx + 1], 10);
69
- if (isNaN(port)) {
70
- console.error("Invalid port number");
75
+ function healthHost(hostname?: string): string {
76
+ return !hostname || hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname;
77
+ }
78
+
79
+ async function proxyHealthy(port?: number): Promise<boolean> {
80
+ const config = loadConfig();
81
+ const p = port ?? config.port ?? 10100;
82
+ try {
83
+ const res = await fetch(`http://${healthHost(config.hostname)}:${p}/healthz`, {
84
+ signal: AbortSignal.timeout(750),
85
+ });
86
+ return res.ok;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ async function waitForProxy(timeoutMs = 8_000): Promise<number | null> {
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ const config = loadConfig();
96
+ const port = config.port ?? 10100;
97
+ if (await proxyHealthy(port)) return port;
98
+ await new Promise(resolve => setTimeout(resolve, 150));
99
+ }
100
+ return null;
101
+ }
102
+
103
+ async function chooseListenPort(requestedPort?: number): Promise<number> {
104
+ const config = loadConfig();
105
+ const preferred = requestedPort ?? config.port ?? 10100;
106
+ const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1");
107
+ if (selected !== preferred) {
108
+ console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
109
+ }
110
+ if (config.port !== selected) {
111
+ config.port = selected;
112
+ saveConfig(config);
113
+ }
114
+ return selected;
115
+ }
116
+
117
+ async function handleStart(options: { block?: boolean } = {}) {
118
+ const existingPid = readPid();
119
+ if (existingPid) {
120
+ const config = loadConfig();
121
+ if (await proxyHealthy(config.port)) {
122
+ console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
71
123
  process.exit(1);
72
124
  }
125
+ removePid();
73
126
  }
74
127
 
128
+ const requestedPort = parsePortOption();
129
+ const port = await chooseListenPort(requestedPort);
130
+
75
131
  const server = startServer(port);
76
132
  writePid(process.pid);
77
133
 
@@ -96,6 +152,39 @@ async function handleStart(options: { block?: boolean } = {}) {
96
152
  }
97
153
  }
98
154
 
155
+ async function handleEnsure() {
156
+ let config = loadConfig();
157
+ if (!codexAutoStartEnabled(config)) {
158
+ console.log("Codex autostart is disabled.");
159
+ return;
160
+ }
161
+ if (await proxyHealthy(config.port)) {
162
+ await syncModelsToCodex(config.port).catch(e => {
163
+ console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
164
+ });
165
+ console.log(`✅ Proxy running on port ${config.port}`);
166
+ return;
167
+ }
168
+
169
+ const child = spawn(process.execPath, [process.argv[1], "start"], {
170
+ detached: true,
171
+ stdio: "ignore",
172
+ env: { ...process.env, OCX_SERVICE: "1" },
173
+ });
174
+ child.unref();
175
+
176
+ const port = await waitForProxy();
177
+ if (!port) {
178
+ console.error("❌ Proxy did not become healthy after starting.");
179
+ process.exit(1);
180
+ }
181
+ config = loadConfig();
182
+ await syncModelsToCodex(config.port ?? port).catch(e => {
183
+ console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
184
+ });
185
+ console.log(`✅ Proxy running on port ${config.port ?? port}`);
186
+ }
187
+
99
188
  function killProxy(pid: number): void {
100
189
  if (!isProcessAlive(pid)) return;
101
190
  if (process.platform === "win32") {
@@ -154,6 +243,58 @@ function handleStop() {
154
243
  if (stopFailed) process.exit(1);
155
244
  }
156
245
 
246
+ async function handleUninstall() {
247
+ const failures: string[] = [];
248
+
249
+ const runStep = (label: string, step: () => void | boolean) => {
250
+ try {
251
+ const changed = step();
252
+ if (changed === false) console.log(`- ${label}: not installed`);
253
+ else console.log(`✅ ${label}`);
254
+ } catch (err) {
255
+ failures.push(label);
256
+ console.error(`⚠️ ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
257
+ }
258
+ };
259
+
260
+ runStep("service removed", () => {
261
+ stopServiceIfInstalled();
262
+ return uninstallServiceIfInstalled();
263
+ });
264
+
265
+ runStep("proxy stopped", () => {
266
+ const pid = readPid();
267
+ if (!pid) return false;
268
+ killProxy(pid);
269
+ removePid();
270
+ return true;
271
+ });
272
+
273
+ runStep("native Codex restored", () => {
274
+ const r = restoreNativeCodex();
275
+ if (!r.success) throw new Error(r.message);
276
+ });
277
+
278
+ try {
279
+ const { uninstallCodexShim } = await import("./codex-shim");
280
+ const r = uninstallCodexShim();
281
+ console.log(r.removed ? "✅ Codex autostart shim removed" : "- Codex autostart shim removed: not installed");
282
+ } catch (err) {
283
+ failures.push("Codex autostart shim removed");
284
+ console.error(`⚠️ Codex autostart shim removed failed: ${err instanceof Error ? err.message : String(err)}`);
285
+ }
286
+
287
+ runStep("opencodex config removed", () => {
288
+ rmSync(getConfigDir(), { recursive: true, force: true });
289
+ });
290
+
291
+ if (failures.length > 0) {
292
+ console.error(`\nUninstall finished with ${failures.length} failed step(s): ${failures.join(", ")}`);
293
+ process.exit(1);
294
+ }
295
+ console.log("\n✅ opencodex local state removed. Remove the package with: npm uninstall -g @bitkyc08/opencodex");
296
+ }
297
+
157
298
  function handleStatus() {
158
299
  const pid = readPid();
159
300
  if (pid) {
@@ -182,9 +323,16 @@ switch (command) {
182
323
  console.log("Plain `codex` now runs natively (no proxy).");
183
324
  break;
184
325
  }
326
+ case "uninstall":
327
+ case "remove":
328
+ await handleUninstall();
329
+ break;
185
330
  case "status":
186
331
  handleStatus();
187
332
  break;
333
+ case "ensure":
334
+ await handleEnsure();
335
+ break;
188
336
  case "login": {
189
337
  const { handleLogin } = await import("./oauth/login-cli");
190
338
  await handleLogin(args[1]);
@@ -201,6 +349,11 @@ switch (command) {
201
349
  await syncModelsToCodex();
202
350
  break;
203
351
  }
352
+ case "sync-cache": {
353
+ const { invalidateCodexModelsCache } = await import("./codex-catalog");
354
+ invalidateCodexModelsCache();
355
+ break;
356
+ }
204
357
  case "gui": {
205
358
  const cfg = await import("./config");
206
359
  const config = cfg.loadConfig();
@@ -248,7 +401,7 @@ switch (command) {
248
401
  }
249
402
  case "update": {
250
403
  const { runUpdate } = await import("./update");
251
- runUpdate();
404
+ await runUpdate();
252
405
  break;
253
406
  }
254
407
  case "help":
@@ -150,6 +150,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
150
150
  // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
151
151
  entry.web_search_tool_type = "text_and_image";
152
152
  entry.supports_search_tool = true;
153
+ entry.supports_parallel_tool_calls = false;
153
154
  return ensureStrictCatalogFields(entry);
154
155
  }
155
156
 
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { CODEX_HOME } from "./codex-paths";
5
+
6
+ const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
7
+ const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
8
+
9
+ type CodexHistoryProvider = "openai" | "opencodex";
10
+
11
+ interface ThreadRow {
12
+ id: string;
13
+ rollout_path: string;
14
+ }
15
+
16
+ function updateSessionMetaProvider(path: string, provider: CodexHistoryProvider): boolean {
17
+ if (!path || !existsSync(path)) return false;
18
+ const stat = statSync(path);
19
+ const raw = readFileSync(path, "utf8");
20
+ const newline = raw.indexOf("\n");
21
+ const firstLine = newline === -1 ? raw : raw.slice(0, newline);
22
+ const rest = newline === -1 ? "" : raw.slice(newline);
23
+
24
+ let parsed: unknown;
25
+ try {
26
+ parsed = JSON.parse(firstLine);
27
+ } catch {
28
+ return false;
29
+ }
30
+
31
+ if (!parsed || typeof parsed !== "object") return false;
32
+ const record = parsed as { type?: unknown; payload?: { model_provider?: unknown } };
33
+ if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return false;
34
+ if (record.payload.model_provider === provider) return false;
35
+
36
+ record.payload.model_provider = provider;
37
+ writeFileSync(path, `${JSON.stringify(record)}${rest}`, "utf8");
38
+ utimesSync(path, stat.atime, stat.mtime);
39
+ return true;
40
+ }
41
+
42
+ export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
43
+ if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
44
+ const from = provider === "opencodex" ? "openai" : "opencodex";
45
+ const db = new Database(stateDbPath);
46
+ try {
47
+ const placeholders = RESUMABLE_SOURCES.map(() => "?").join(",");
48
+ const rows = db
49
+ .query<ThreadRow, string[]>(`
50
+ SELECT id, rollout_path
51
+ FROM threads
52
+ WHERE model_provider = ?
53
+ AND source IN (${placeholders})
54
+ `)
55
+ .all(from, ...RESUMABLE_SOURCES);
56
+
57
+ let files = 0;
58
+ for (const row of rows) {
59
+ try {
60
+ if (updateSessionMetaProvider(row.rollout_path, provider)) files++;
61
+ } catch {
62
+ /* best-effort; keep DB migration moving even if one old rollout is malformed */
63
+ }
64
+ }
65
+
66
+ const update = db.transaction(() => {
67
+ db.query(`
68
+ UPDATE threads
69
+ SET has_user_event = 1
70
+ WHERE source IN (${placeholders})
71
+ AND trim(coalesce(first_user_message, '')) != ''
72
+ `).run(...RESUMABLE_SOURCES);
73
+ db.query(`
74
+ UPDATE threads
75
+ SET model_provider = ?
76
+ WHERE model_provider = ?
77
+ AND source IN (${placeholders})
78
+ `).run(provider, from, ...RESUMABLE_SOURCES);
79
+ });
80
+ update();
81
+
82
+ return { rows: rows.length, files };
83
+ } finally {
84
+ db.close();
85
+ }
86
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { atomicWriteFile, websocketsEnabled } from "./config";
3
3
  import { restoreCodexCatalog } from "./codex-catalog";
4
+ import { syncCodexHistoryProvider } from "./codex-history-provider";
4
5
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./codex-paths";
5
6
  import type { OcxConfig } from "./types";
6
7
 
@@ -228,14 +229,19 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
228
229
 
229
230
  writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
230
231
  writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
232
+ const history = syncCodexHistoryProvider("opencodex");
231
233
 
232
234
  const catalogMessage = catalogPath
233
235
  ? ` Codex model catalog: ${catalogPath}\n`
234
236
  : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
237
+ const historyMessage = history.rows > 0
238
+ ? ` Codex resume history: ${history.rows} thread(s) mapped to opencodex.\n`
239
+ : "";
235
240
  return {
236
241
  success: true,
237
242
  message: `Injected opencodex as default provider into Codex config.\n` +
238
243
  catalogMessage +
244
+ historyMessage +
239
245
  ` All models now route through opencodex proxy (like OpenRouter).\n` +
240
246
  ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
241
247
  ` Custom models route to their configured providers.\n` +
@@ -311,10 +317,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
311
317
  export function restoreNativeCodex(): { success: boolean; message: string } {
312
318
  const cfg = removeCodexConfig();
313
319
  const cat = restoreCodexCatalog();
320
+ const history = syncCodexHistoryProvider("openai");
314
321
  const msg = cat.removed > 0
315
322
  ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
316
323
  : cfg.message;
317
- return { success: cfg.success, message: msg };
324
+ const historyMsg = history.rows > 0 ? ` Resume history restored to openai (${history.rows} thread(s)).` : "";
325
+ return { success: cfg.success, message: `${msg}${historyMsg}` };
318
326
  }
319
327
 
320
328
  export function getCodexConfigPath(): string {
package/src/codex-shim.ts CHANGED
@@ -4,6 +4,30 @@ import { getConfigDir } from "./config";
4
4
 
5
5
  const SHIM_MARKER = "opencodex codex autostart shim";
6
6
  const STATE_PATH = join(getConfigDir(), "codex-shim.json");
7
+ const CODEX_INTERNAL_COMMANDS = [
8
+ "app-server",
9
+ "archive",
10
+ "apply",
11
+ "cloud",
12
+ "completion",
13
+ "debug",
14
+ "delete",
15
+ "doctor",
16
+ "exec",
17
+ "exec-server",
18
+ "features",
19
+ "fork",
20
+ "help",
21
+ "login",
22
+ "logout",
23
+ "mcp",
24
+ "plugin",
25
+ "resume",
26
+ "review",
27
+ "sandbox",
28
+ "unarchive",
29
+ "update",
30
+ ];
7
31
 
8
32
  interface ShimState {
9
33
  platform: NodeJS.Platform;
@@ -51,39 +75,33 @@ function backupPathFor(path: string): string {
51
75
  }
52
76
 
53
77
  export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
78
+ const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
54
79
  return `#!/usr/bin/env sh
55
80
  # ${SHIM_MARKER}
56
- if [ -z "$OCX_SHIM_BYPASS" ]; then
57
- if ! "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then
58
- mkdir -p "$HOME/.opencodex"
59
- nohup env OCX_SERVICE=1 "${bunPath}" "${cliPath}" start >> "$HOME/.opencodex/shim.log" 2>&1 &
60
- i=0
61
- while [ "$i" -lt 50 ]; do
62
- if "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then
63
- break
64
- fi
65
- sleep 0.1
66
- i=$((i + 1))
67
- done
68
- fi
69
- fi
81
+ case "$1" in
82
+ ${internalCommands}|--help|-h|--version|-V)
83
+ ;;
84
+ *)
85
+ if [ -z "$OCX_SHIM_BYPASS" ]; then
86
+ "${bunPath}" "${cliPath}" ensure >/dev/null 2>&1 || true
87
+ fi
88
+ ;;
89
+ esac
70
90
  exec "${realCodexPath}" "$@"
71
91
  `;
72
92
  }
73
93
 
74
94
  export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
95
+ const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
75
96
  return `@echo off\r
76
97
  rem ${SHIM_MARKER}\r
77
98
  if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
78
- "${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r
79
- if %ERRORLEVEL% EQU 0 goto run_codex\r
80
- if not exist "%USERPROFILE%\\.opencodex" mkdir "%USERPROFILE%\\.opencodex"\r
81
- start "" /b cmd /c "set OCX_SERVICE=1 && ""${bunPath}"" ""${cliPath}"" start >> ""%USERPROFILE%\\.opencodex\\shim.log"" 2>&1"\r
82
- for /l %%i in (1,1,50) do (\r
83
- "${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r
84
- if not errorlevel 1 goto run_codex\r
85
- powershell -NoProfile -Command "Start-Sleep -Milliseconds 100" >nul 2>nul\r
86
- )\r
99
+ ${internalCommandChecks}\r
100
+ if /I "%~1"=="--help" goto run_codex\r
101
+ if /I "%~1"=="-h" goto run_codex\r
102
+ if /I "%~1"=="--version" goto run_codex\r
103
+ if /I "%~1"=="-V" goto run_codex\r
104
+ "${bunPath}" "${cliPath}" ensure >nul 2>nul\r
87
105
  :run_codex\r
88
106
  "${realCodexPath}" %*\r
89
107
  `;
@@ -102,11 +120,39 @@ function writeState(state: ShimState): void {
102
120
  writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf8");
103
121
  }
104
122
 
123
+ function writeShim(wrapperPath: string, realCodexPath: string): void {
124
+ const { bun, cli } = cliEntry();
125
+ if (process.platform === "win32") {
126
+ writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
127
+ } else {
128
+ writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8");
129
+ chmodSync(wrapperPath, 0o755);
130
+ }
131
+ }
132
+
105
133
  export function installCodexShim(): { installed: boolean; message: string } {
106
134
  const existing = readState();
107
- if (existing && existsSync(existing.wrapperPath) && existsSync(existing.backupPath)) {
135
+ if (existing && existsSync(existing.wrapperPath) && existsSync(existing.backupPath) && isShim(existing.wrapperPath)) {
136
+ if (process.platform === "win32" && existing.originalPath && existsSync(existing.originalPath)) {
137
+ renameSync(existing.originalPath, existing.backupPath);
138
+ writeShim(existing.wrapperPath, existing.backupPath);
139
+ writeState({ ...existing, platform: process.platform });
140
+ return {
141
+ installed: true,
142
+ message: `Codex update detected. Backed up new binary and refreshed shim at ${existing.wrapperPath}.`,
143
+ };
144
+ }
108
145
  return { installed: false, message: `Codex autostart shim already installed at ${existing.wrapperPath}.` };
109
146
  }
147
+ if (existing && existsSync(existing.backupPath) && (!existsSync(existing.wrapperPath) || !isShim(existing.wrapperPath))) {
148
+ if (existsSync(existing.wrapperPath)) unlinkSync(existing.wrapperPath);
149
+ writeShim(existing.wrapperPath, existing.backupPath);
150
+ writeState({ ...existing, platform: process.platform });
151
+ return {
152
+ installed: true,
153
+ message: `Codex autostart shim repaired at ${existing.wrapperPath}. Original remains at ${existing.backupPath}.`,
154
+ };
155
+ }
110
156
 
111
157
  const originalPath = findCodexOnPath();
112
158
  if (!originalPath) return { installed: false, message: "Could not find a codex executable on PATH." };
@@ -114,15 +160,9 @@ export function installCodexShim(): { installed: boolean; message: string } {
114
160
  const backupPath = backupPathFor(originalPath);
115
161
  if (existsSync(backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${backupPath}` };
116
162
 
117
- const { bun, cli } = cliEntry();
118
163
  const wrapperPath = process.platform === "win32" ? join(dirname(originalPath), "codex.cmd") : originalPath;
119
164
  renameSync(originalPath, backupPath);
120
- if (process.platform === "win32") {
121
- writeFileSync(wrapperPath, buildWindowsCodexShim(backupPath, bun, cli), "utf8");
122
- } else {
123
- writeFileSync(wrapperPath, buildUnixCodexShim(backupPath, bun, cli), "utf8");
124
- chmodSync(wrapperPath, 0o755);
125
- }
165
+ writeShim(wrapperPath, backupPath);
126
166
  writeState({ platform: process.platform, wrapperPath, originalPath, backupPath });
127
167
  return { installed: true, message: `Codex autostart shim installed at ${wrapperPath}. Original saved at ${backupPath}.` };
128
168
  }
@@ -139,7 +179,11 @@ export function uninstallCodexShim(): { removed: boolean; message: string } {
139
179
  export function codexShimStatus(): string {
140
180
  const state = readState();
141
181
  if (!state) return "Codex autostart shim is not installed.";
142
- const wrapper = existsSync(state.wrapperPath) ? "present" : "missing";
182
+ const wrapper = existsSync(state.wrapperPath)
183
+ ? isShim(state.wrapperPath)
184
+ ? "shim present"
185
+ : "present but not an opencodex shim"
186
+ : "missing";
143
187
  const backup = existsSync(state.backupPath) ? "present" : "missing";
144
188
  return `Codex autostart shim: wrapper ${wrapper} at ${state.wrapperPath}; original backup ${backup} at ${state.backupPath}.`;
145
189
  }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { OcxConfig } from "./types";
@@ -10,7 +10,7 @@ let _atomicSeq = 0;
10
10
  */
11
11
  export function atomicWriteFile(path: string, content: string): void {
12
12
  const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
13
- writeFileSync(tmp, content, "utf-8");
13
+ writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
14
14
  renameSync(tmp, path);
15
15
  }
16
16
 
@@ -39,7 +39,22 @@ export function getPidPath(): string {
39
39
  return PID_PATH;
40
40
  }
41
41
 
42
+ export function hardenConfigDir(): void {
43
+ if (existsSync(OCX_DIR)) {
44
+ try { chmodSync(OCX_DIR, 0o700); } catch { /* best-effort */ }
45
+ }
46
+ }
47
+
48
+ export function hardenExistingSecret(path: string): void {
49
+ if (existsSync(path)) {
50
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
51
+ }
52
+ }
53
+
42
54
  export function loadConfig(): OcxConfig {
55
+ hardenConfigDir();
56
+ hardenExistingSecret(CONFIG_PATH);
57
+ hardenExistingSecret(join(OCX_DIR, "auth.json"));
43
58
  if (!existsSync(CONFIG_PATH)) {
44
59
  return getDefaultConfig();
45
60
  }
@@ -53,15 +68,21 @@ export function loadConfig(): OcxConfig {
53
68
 
54
69
  export function saveConfig(config: OcxConfig): void {
55
70
  if (!existsSync(OCX_DIR)) {
56
- mkdirSync(OCX_DIR, { recursive: true });
71
+ mkdirSync(OCX_DIR, { recursive: true, mode: 0o700 });
72
+ } else {
73
+ try { chmodSync(OCX_DIR, 0o700); } catch { /* best-effort on existing dir */ }
57
74
  }
58
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
75
+ atomicWriteFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
59
76
  }
60
77
 
61
78
  export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
62
79
  return config.websockets === true;
63
80
  }
64
81
 
82
+ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
83
+ return config.codexAutoStart !== false;
84
+ }
85
+
65
86
  export function getDefaultConfig(): OcxConfig {
66
87
  // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
67
88
  // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
@@ -78,6 +99,7 @@ export function getDefaultConfig(): OcxConfig {
78
99
  defaultProvider: "openai",
79
100
  subagentModels: [...DEFAULT_SUBAGENT_MODELS],
80
101
  websockets: false,
102
+ codexAutoStart: true,
81
103
  };
82
104
  }
83
105
 
@@ -90,7 +112,11 @@ export function resolveEnvValue(value: string | undefined): string | undefined {
90
112
  }
91
113
 
92
114
  export function writePid(pid: number): void {
93
- if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
115
+ if (!existsSync(OCX_DIR)) {
116
+ mkdirSync(OCX_DIR, { recursive: true, mode: 0o700 });
117
+ } else {
118
+ hardenConfigDir();
119
+ }
94
120
  writeFileSync(PID_PATH, String(pid), "utf-8");
95
121
  }
96
122
 
package/src/init.ts CHANGED
@@ -131,6 +131,17 @@ export async function runInit(): Promise<void> {
131
131
  console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
132
132
  }
133
133
 
134
+ const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
135
+ if (shimAnswer.trim().toLowerCase() !== "n") {
136
+ try {
137
+ const { installCodexShim } = await import("./codex-shim");
138
+ const result = installCodexShim();
139
+ console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
140
+ } catch (err) {
141
+ console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
142
+ }
143
+ }
144
+
134
145
  console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
135
146
  prompt.close();
136
147
  }
@@ -1,13 +1,15 @@
1
1
  /** OAuth token store at ~/.opencodex/auth.json, keyed by provider name. */
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { getConfigDir } from "../config";
4
+ import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
5
5
  import type { OAuthCredentials } from "./types";
6
6
 
7
7
  const AUTH_PATH = join(getConfigDir(), "auth.json");
8
8
  type AuthStore = Record<string, OAuthCredentials>;
9
9
 
10
10
  export function loadAuthStore(): AuthStore {
11
+ hardenConfigDir();
12
+ hardenExistingSecret(AUTH_PATH);
11
13
  if (!existsSync(AUTH_PATH)) return {};
12
14
  try {
13
15
  return JSON.parse(readFileSync(AUTH_PATH, "utf-8")) as AuthStore;
@@ -18,8 +20,12 @@ export function loadAuthStore(): AuthStore {
18
20
 
19
21
  function persist(store: AuthStore): void {
20
22
  const dir = getConfigDir();
21
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
- writeFileSync(AUTH_PATH, JSON.stringify(store, null, 2) + "\n", "utf-8");
23
+ if (!existsSync(dir)) {
24
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ } else {
26
+ try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
27
+ }
28
+ atomicWriteFile(AUTH_PATH, JSON.stringify(store, null, 2) + "\n");
23
29
  }
24
30
 
25
31
  export function getCredential(provider: string): OAuthCredentials | null {