@bitkyc08/opencodex 2.5.6 → 2.6.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.
- package/README.ko.md +17 -6
- package/README.md +19 -7
- package/README.zh-CN.md +12 -3
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +88 -2
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BS4X1QDi.js +9 -0
- package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +20 -6
- package/src/adapters/anthropic.ts +16 -5
- package/src/adapters/google.ts +9 -2
- package/src/adapters/openai-chat.ts +13 -5
- package/src/bun-runtime.ts +22 -1
- package/src/cli-help.ts +111 -0
- package/src/cli-status.ts +164 -0
- package/src/cli.ts +77 -186
- package/src/codex-account-store.ts +47 -8
- package/src/codex-auth-api.ts +111 -54
- package/src/codex-auth-collision.ts +5 -0
- package/src/codex-catalog.ts +24 -12
- package/src/codex-history-provider.ts +29 -13
- package/src/codex-inject.ts +46 -29
- package/src/codex-journal.ts +77 -13
- package/src/codex-quota.ts +11 -3
- package/src/codex-routing.ts +14 -4
- package/src/codex-shim.ts +71 -24
- package/src/codex-websocket-registry.ts +20 -4
- package/src/config.ts +138 -4
- package/src/init.ts +7 -2
- package/src/oauth/callback-server.ts +22 -15
- package/src/oauth/index.ts +18 -4
- package/src/oauth/login-cli.ts +8 -1
- package/src/oauth/store.ts +2 -1
- package/src/process-control.ts +36 -0
- package/src/provider-label.ts +8 -0
- package/src/responses/parser.ts +18 -1
- package/src/router.ts +61 -5
- package/src/server.ts +878 -94
- package/src/service-secrets.ts +6 -0
- package/src/service.ts +293 -28
- package/src/types.ts +26 -1
- package/src/update.ts +16 -9
- package/src/usage-debug.ts +65 -0
- package/src/usage-log.ts +62 -0
- package/src/usage-summary.ts +0 -0
- package/src/ws-bridge.ts +2 -2
- package/gui/README.md +0 -73
- package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/service.ts
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* restore it via the command.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
|
-
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { getConfigDir } from "./config";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { getConfigDir, readPid, removePid } from "./config";
|
|
13
|
+
import { loadConfig } from "./config";
|
|
13
14
|
import { restoreNativeCodex } from "./codex-inject";
|
|
14
|
-
import { durableBunPath } from "./bun-runtime";
|
|
15
|
+
import { durableBunPath, durableBunRuntime } from "./bun-runtime";
|
|
16
|
+
import { isProcessAlive, killProxy } from "./process-control";
|
|
17
|
+
import { serviceApiTokenFilePath } from "./service-secrets";
|
|
15
18
|
|
|
16
19
|
const LABEL = "com.opencodex.proxy";
|
|
17
20
|
const TASK = "opencodex-proxy";
|
|
@@ -31,10 +34,99 @@ function logPath(): string {
|
|
|
31
34
|
return join(getConfigDir(), "service.log");
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
export function serviceLogPath(): string {
|
|
38
|
+
return logPath();
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
function windowsServiceScriptPath(): string {
|
|
35
42
|
return join(getConfigDir(), "opencodex-service.cmd");
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
function windowsTaskXmlPath(): string {
|
|
46
|
+
return join(getConfigDir(), "opencodex-service-task.xml");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serviceStatePath(): string {
|
|
50
|
+
return join(getConfigDir(), "service-state.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultOpenCodexHome(): string {
|
|
54
|
+
return resolve(join(homedir(), ".opencodex"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function serviceStatePaths(): string[] {
|
|
58
|
+
const paths = [serviceStatePath()];
|
|
59
|
+
const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
|
|
60
|
+
if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
|
|
61
|
+
return paths;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function currentCodexHome(): string {
|
|
65
|
+
return resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function currentOpenCodexHome(): string {
|
|
69
|
+
return resolve(process.env.OPENCODEX_HOME?.trim() || getConfigDir());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizePathForCompare(path: string): string {
|
|
73
|
+
const resolved = resolve(path);
|
|
74
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ServiceInstallState {
|
|
78
|
+
version: 1;
|
|
79
|
+
codexHome: string;
|
|
80
|
+
opencodexHome: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeServiceInstallState(): void {
|
|
84
|
+
const state: ServiceInstallState = {
|
|
85
|
+
version: 1,
|
|
86
|
+
codexHome: currentCodexHome(),
|
|
87
|
+
opencodexHome: currentOpenCodexHome(),
|
|
88
|
+
};
|
|
89
|
+
for (const path of serviceStatePaths()) {
|
|
90
|
+
const dir = dirname(path);
|
|
91
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
92
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
93
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readServiceInstallState(): ServiceInstallState | null {
|
|
98
|
+
for (const path of serviceStatePaths()) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as ServiceInstallState;
|
|
101
|
+
if (parsed.version === 1) return parsed;
|
|
102
|
+
} catch {
|
|
103
|
+
/* try the next known state path */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
110
|
+
const state = readServiceInstallState();
|
|
111
|
+
if (!state) return;
|
|
112
|
+
const expected = normalizePathForCompare(state.codexHome);
|
|
113
|
+
const actual = normalizePathForCompare(currentCodexHome());
|
|
114
|
+
if (expected !== actual) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
|
|
117
|
+
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
|
|
121
|
+
const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
|
|
122
|
+
if (expectedOpenCodexHome !== actualOpenCodexHome) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
|
|
125
|
+
"Run the service command from the same OpenCodex home so service state and secrets match.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
38
130
|
function plistString(value: string): string {
|
|
39
131
|
return value
|
|
40
132
|
.replace(/&/g, "&")
|
|
@@ -44,12 +136,45 @@ function plistString(value: string): string {
|
|
|
44
136
|
.replace(/'/g, "'");
|
|
45
137
|
}
|
|
46
138
|
|
|
139
|
+
function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
140
|
+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
|
|
141
|
+
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function assertServiceAuthEnvironment(): void {
|
|
145
|
+
const config = loadConfig();
|
|
146
|
+
if (isLoopbackHostname(config.hostname)) return;
|
|
147
|
+
if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
|
|
148
|
+
throw new Error(
|
|
149
|
+
"OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " +
|
|
150
|
+
"Set it in the same shell, then rerun `ocx service install`.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeServiceApiTokenFile(): string | null {
|
|
155
|
+
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
156
|
+
if (!token) return null;
|
|
157
|
+
const path = serviceApiTokenFilePath();
|
|
158
|
+
const dir = getConfigDir();
|
|
159
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
160
|
+
writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
161
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
162
|
+
return path;
|
|
163
|
+
}
|
|
164
|
+
|
|
47
165
|
export function buildPlist(): string {
|
|
48
166
|
const { bun, cli } = cliEntry();
|
|
49
167
|
const log = logPath();
|
|
50
168
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
51
169
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
52
|
-
const
|
|
170
|
+
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
171
|
+
const envLines = [
|
|
172
|
+
` <key>OCX_SERVICE</key><string>1</string>`,
|
|
173
|
+
` <key>PATH</key><string>${plistString(path)}</string>`,
|
|
174
|
+
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
175
|
+
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
176
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
177
|
+
const command = buildServiceShellCommand(bun, cli);
|
|
53
178
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
54
179
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
55
180
|
<plist version="1.0">
|
|
@@ -57,17 +182,16 @@ export function buildPlist(): string {
|
|
|
57
182
|
<key>Label</key><string>${LABEL}</string>
|
|
58
183
|
<key>ProgramArguments</key>
|
|
59
184
|
<array>
|
|
60
|
-
<string
|
|
61
|
-
<string
|
|
62
|
-
<string
|
|
185
|
+
<string>/bin/sh</string>
|
|
186
|
+
<string>-lc</string>
|
|
187
|
+
<string>${plistString(command)}</string>
|
|
63
188
|
</array>
|
|
64
189
|
<key>RunAtLoad</key><true/>
|
|
65
190
|
<key>KeepAlive</key><true/>
|
|
66
191
|
<key>EnvironmentVariables</key>
|
|
67
192
|
<dict>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
|
|
193
|
+
${envLines}
|
|
194
|
+
</dict>
|
|
71
195
|
<key>StandardOutPath</key><string>${plistString(log)}</string>
|
|
72
196
|
<key>StandardErrorPath</key><string>${plistString(log)}</string>
|
|
73
197
|
</dict>
|
|
@@ -75,6 +199,15 @@ ${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
|
|
|
75
199
|
`;
|
|
76
200
|
}
|
|
77
201
|
|
|
202
|
+
function shellQuote(value: string): string {
|
|
203
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildServiceShellCommand(bun: string, cli: string): string {
|
|
207
|
+
const tokenFile = serviceApiTokenFilePath();
|
|
208
|
+
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start`;
|
|
209
|
+
}
|
|
210
|
+
|
|
78
211
|
function systemdQuote(value: string): string {
|
|
79
212
|
return `"${value
|
|
80
213
|
.replace(/\\/g, "\\\\")
|
|
@@ -112,7 +245,11 @@ function schtasks(args: string[]): string {
|
|
|
112
245
|
}
|
|
113
246
|
|
|
114
247
|
function windowsBatchValue(value: string): string {
|
|
115
|
-
return value
|
|
248
|
+
return value
|
|
249
|
+
.replace(/%/g, "%%")
|
|
250
|
+
.replace(/\^/g, "^^")
|
|
251
|
+
.replace(/"/g, "")
|
|
252
|
+
.replace(/[\r\n]/g, "");
|
|
116
253
|
}
|
|
117
254
|
|
|
118
255
|
function windowsBatchSet(name: string, value: string | undefined): string | null {
|
|
@@ -120,8 +257,18 @@ function windowsBatchSet(name: string, value: string | undefined): string | null
|
|
|
120
257
|
return `set "${name}=${windowsBatchValue(value)}"`;
|
|
121
258
|
}
|
|
122
259
|
|
|
123
|
-
|
|
124
|
-
|
|
260
|
+
function taskXmlString(value: string): string {
|
|
261
|
+
return value
|
|
262
|
+
.replace(/&/g, "&")
|
|
263
|
+
.replace(/</g, "<")
|
|
264
|
+
.replace(/>/g, ">")
|
|
265
|
+
.replace(/"/g, """)
|
|
266
|
+
.replace(/'/g, "'");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function buildWindowsServiceScript(entry = cliEntry()): string {
|
|
270
|
+
const { bun, cli } = entry;
|
|
271
|
+
const bunRuntime = durableBunRuntime();
|
|
125
272
|
const path = process.env.PATH ?? "";
|
|
126
273
|
const lines = [
|
|
127
274
|
"@echo off",
|
|
@@ -129,9 +276,25 @@ export function buildWindowsServiceScript(): string {
|
|
|
129
276
|
windowsBatchSet("OCX_SERVICE", "1"),
|
|
130
277
|
windowsBatchSet("PATH", path),
|
|
131
278
|
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
|
|
279
|
+
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()),
|
|
280
|
+
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath()),
|
|
281
|
+
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath()),
|
|
282
|
+
windowsBatchSet("OCX_BUN", bun),
|
|
283
|
+
windowsBatchSet("OCX_CLI", cli),
|
|
284
|
+
'if exist "%OCX_API_TOKEN_FILE%" (',
|
|
285
|
+
' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
|
|
286
|
+
")",
|
|
132
287
|
":loop",
|
|
133
|
-
|
|
288
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
|
|
289
|
+
'>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
|
|
290
|
+
`>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`,
|
|
291
|
+
'>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
|
|
292
|
+
'>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
|
|
293
|
+
'>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
|
|
294
|
+
'>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
|
|
295
|
+
'"%OCX_BUN%" "%OCX_CLI%" start >>"%OCX_SERVICE_LOG%" 2>&1',
|
|
134
296
|
"if %ERRORLEVEL% NEQ 0 (",
|
|
297
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
135
298
|
" timeout /t 5 /nobreak >nul",
|
|
136
299
|
" goto loop",
|
|
137
300
|
")",
|
|
@@ -141,7 +304,52 @@ export function buildWindowsServiceScript(): string {
|
|
|
141
304
|
}
|
|
142
305
|
|
|
143
306
|
export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
|
|
144
|
-
|
|
307
|
+
const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
|
|
308
|
+
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string {
|
|
312
|
+
const escapedScript = taskXmlString(script);
|
|
313
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
314
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
315
|
+
<RegistrationInfo>
|
|
316
|
+
<Description>OpenCodex proxy service wrapper</Description>
|
|
317
|
+
</RegistrationInfo>
|
|
318
|
+
<Triggers>
|
|
319
|
+
<LogonTrigger>
|
|
320
|
+
<Enabled>true</Enabled>
|
|
321
|
+
</LogonTrigger>
|
|
322
|
+
</Triggers>
|
|
323
|
+
<Principals>
|
|
324
|
+
<Principal id="Author">
|
|
325
|
+
<LogonType>InteractiveToken</LogonType>
|
|
326
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
327
|
+
</Principal>
|
|
328
|
+
</Principals>
|
|
329
|
+
<Settings>
|
|
330
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
331
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
332
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
333
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
334
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
335
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
336
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
337
|
+
<Enabled>true</Enabled>
|
|
338
|
+
<Hidden>false</Hidden>
|
|
339
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
340
|
+
<Priority>7</Priority>
|
|
341
|
+
<RestartOnFailure>
|
|
342
|
+
<Interval>PT1M</Interval>
|
|
343
|
+
<Count>3</Count>
|
|
344
|
+
</RestartOnFailure>
|
|
345
|
+
</Settings>
|
|
346
|
+
<Actions Context="Author">
|
|
347
|
+
<Exec>
|
|
348
|
+
<Command>${escapedScript}</Command>
|
|
349
|
+
</Exec>
|
|
350
|
+
</Actions>
|
|
351
|
+
</Task>
|
|
352
|
+
`;
|
|
145
353
|
}
|
|
146
354
|
|
|
147
355
|
// ── macOS (launchd) ──
|
|
@@ -149,10 +357,12 @@ function installLaunchd(): void {
|
|
|
149
357
|
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
150
358
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
151
359
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
360
|
+
writeServiceApiTokenFile();
|
|
152
361
|
const p = plistPath();
|
|
153
362
|
writeFileSync(p, buildPlist(), "utf8");
|
|
154
363
|
try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
|
|
155
364
|
sh(`launchctl load -w "${p}"`);
|
|
365
|
+
writeServiceInstallState();
|
|
156
366
|
}
|
|
157
367
|
function startLaunchd(): void { sh(`launchctl load -w "${plistPath()}"`); }
|
|
158
368
|
function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
|
|
@@ -166,10 +376,14 @@ function uninstallLaunchd(): void {
|
|
|
166
376
|
// ── Windows (Task Scheduler) ──
|
|
167
377
|
function installWindows(): void {
|
|
168
378
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
379
|
+
writeServiceApiTokenFile();
|
|
169
380
|
const script = windowsServiceScriptPath();
|
|
170
381
|
writeFileSync(script, buildWindowsServiceScript(), "utf8");
|
|
382
|
+
writeFileSync(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
383
|
+
try { stopWindows(); } catch { /* not running */ }
|
|
171
384
|
schtasks(buildWindowsSchtasksCreateArgs(script));
|
|
172
385
|
schtasks(["/run", "/tn", TASK]);
|
|
386
|
+
writeServiceInstallState();
|
|
173
387
|
}
|
|
174
388
|
function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
|
|
175
389
|
function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
|
|
@@ -177,6 +391,11 @@ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]
|
|
|
177
391
|
function uninstallWindows(): void {
|
|
178
392
|
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
|
|
179
393
|
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
|
|
394
|
+
if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function serviceDiagnosticsSummary(): string {
|
|
398
|
+
return `logs: ${serviceLogPath()}`;
|
|
180
399
|
}
|
|
181
400
|
|
|
182
401
|
// ── Linux (systemd user unit) ──
|
|
@@ -193,10 +412,12 @@ export function buildUnit(): string {
|
|
|
193
412
|
const log = logPath();
|
|
194
413
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
195
414
|
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
|
|
415
|
+
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
|
|
196
416
|
const envLines = [
|
|
197
417
|
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
198
418
|
systemdEnvironmentAssignment("PATH", path),
|
|
199
419
|
codexHome,
|
|
420
|
+
opencodexHome,
|
|
200
421
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
201
422
|
return `[Unit]
|
|
202
423
|
Description=OpenCodex Proxy Server
|
|
@@ -205,7 +426,7 @@ Wants=network-online.target
|
|
|
205
426
|
|
|
206
427
|
[Service]
|
|
207
428
|
Type=simple
|
|
208
|
-
ExecStart=${systemdQuote(
|
|
429
|
+
ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))}
|
|
209
430
|
Restart=on-failure
|
|
210
431
|
RestartSec=5
|
|
211
432
|
${envLines}
|
|
@@ -218,16 +439,20 @@ WantedBy=default.target
|
|
|
218
439
|
}
|
|
219
440
|
|
|
220
441
|
function isSystemd(): boolean {
|
|
221
|
-
try { execSync("systemctl --version", { stdio: "pipe" });
|
|
442
|
+
try { execSync("systemctl --version", { stdio: "pipe" }); } catch { return false; }
|
|
443
|
+
try { execSync("systemctl --user show-environment", { stdio: "pipe" }); return true; } catch { return false; }
|
|
222
444
|
}
|
|
223
445
|
|
|
224
446
|
function installSystemd(): void {
|
|
225
447
|
const dir = unitDir();
|
|
226
448
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
227
449
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
450
|
+
writeServiceApiTokenFile();
|
|
228
451
|
writeFileSync(unitPath(), buildUnit(), "utf8");
|
|
229
452
|
sh("systemctl --user daemon-reload");
|
|
230
|
-
sh(`systemctl --user enable
|
|
453
|
+
sh(`systemctl --user enable ${TASK}`);
|
|
454
|
+
sh(`systemctl --user restart ${TASK}`);
|
|
455
|
+
writeServiceInstallState();
|
|
231
456
|
}
|
|
232
457
|
function startSystemd(): void { sh(`systemctl --user start ${TASK}`); }
|
|
233
458
|
function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
|
|
@@ -253,7 +478,7 @@ function platformOps(): ServiceOps | null {
|
|
|
253
478
|
console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
|
|
254
479
|
process.exit(1);
|
|
255
480
|
}
|
|
256
|
-
if (!isSystemd()) {
|
|
481
|
+
if (!isSystemd() && !existsSync(unitPath())) {
|
|
257
482
|
console.error("systemd not found. Run 'ocx start' under your process supervisor.");
|
|
258
483
|
process.exit(1);
|
|
259
484
|
}
|
|
@@ -262,11 +487,35 @@ function platformOps(): ServiceOps | null {
|
|
|
262
487
|
return null;
|
|
263
488
|
}
|
|
264
489
|
|
|
490
|
+
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
|
|
491
|
+
|
|
492
|
+
function stopTrackedProxyIfRunning(): TrackedProxyCleanupResult {
|
|
493
|
+
const pid = readPid();
|
|
494
|
+
if (!pid) return "none";
|
|
495
|
+
if (!isProcessAlive(pid)) {
|
|
496
|
+
removePid(pid);
|
|
497
|
+
return "stale";
|
|
498
|
+
}
|
|
499
|
+
killProxy(pid);
|
|
500
|
+
removePid(pid);
|
|
501
|
+
return "stopped";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function stopTrackedProxyForServiceCommand(): TrackedProxyCleanupResult {
|
|
505
|
+
try {
|
|
506
|
+
return stopTrackedProxyIfRunning();
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
|
|
509
|
+
return "none";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
265
513
|
/**
|
|
266
514
|
* If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
|
|
267
515
|
* Returns true if a service was found and stopped.
|
|
268
516
|
*/
|
|
269
517
|
export function stopServiceIfInstalled(): boolean {
|
|
518
|
+
assertServiceEnvironmentMatchesInstall();
|
|
270
519
|
if (process.platform === "darwin") {
|
|
271
520
|
if (existsSync(plistPath())) {
|
|
272
521
|
try { stopLaunchd(); return true; } catch { return false; }
|
|
@@ -288,6 +537,7 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
288
537
|
* manager.
|
|
289
538
|
*/
|
|
290
539
|
export function uninstallServiceIfInstalled(): boolean {
|
|
540
|
+
assertServiceEnvironmentMatchesInstall();
|
|
291
541
|
if (process.platform === "darwin") {
|
|
292
542
|
if (existsSync(plistPath())) {
|
|
293
543
|
try { uninstallLaunchd(); return true; } catch { return false; }
|
|
@@ -297,8 +547,10 @@ export function uninstallServiceIfInstalled(): boolean {
|
|
|
297
547
|
const q = schtasks(["/query", "/tn", TASK]);
|
|
298
548
|
if (q.includes(TASK)) { uninstallWindows(); return true; }
|
|
299
549
|
} catch { /* task not found */ }
|
|
300
|
-
} else if (process.platform === "linux" &&
|
|
301
|
-
try { uninstallSystemd(); return true; } catch {
|
|
550
|
+
} else if (process.platform === "linux" && existsSync(unitPath())) {
|
|
551
|
+
try { uninstallSystemd(); return true; } catch {
|
|
552
|
+
try { unlinkSync(unitPath()); return true; } catch { return false; }
|
|
553
|
+
}
|
|
302
554
|
}
|
|
303
555
|
return false;
|
|
304
556
|
}
|
|
@@ -309,21 +561,22 @@ export function isServiceInstalled(): boolean {
|
|
|
309
561
|
}
|
|
310
562
|
|
|
311
563
|
export function serviceStatusSummary(): string {
|
|
564
|
+
const diagnostics = serviceDiagnosticsSummary();
|
|
312
565
|
if (process.platform === "darwin") {
|
|
313
|
-
if (!existsSync(plistPath())) return
|
|
566
|
+
if (!existsSync(plistPath())) return `not installed (${diagnostics})`;
|
|
314
567
|
const status = statusLaunchd();
|
|
315
|
-
return status ?
|
|
568
|
+
return status ? `installed (launchd; ${diagnostics})` : `installed, not loaded (${diagnostics})`;
|
|
316
569
|
}
|
|
317
570
|
if (process.platform === "win32") {
|
|
318
571
|
const status = statusWindows();
|
|
319
|
-
return status ?
|
|
572
|
+
return status ? `installed (Task Scheduler; ${diagnostics})` : `not installed (${diagnostics})`;
|
|
320
573
|
}
|
|
321
574
|
if (process.platform === "linux") {
|
|
322
575
|
if (existsSync("/.dockerenv")) return "unsupported in Docker";
|
|
323
576
|
if (!isSystemd()) return "unsupported: systemd not found";
|
|
324
|
-
if (!existsSync(unitPath())) return
|
|
577
|
+
if (!existsSync(unitPath())) return `not installed (${diagnostics})`;
|
|
325
578
|
const status = statusSystemd();
|
|
326
|
-
return status ?
|
|
579
|
+
return status ? `installed (systemd user; ${diagnostics})` : `installed, not running (${diagnostics})`;
|
|
327
580
|
}
|
|
328
581
|
return `unsupported on ${process.platform}`;
|
|
329
582
|
}
|
|
@@ -336,6 +589,8 @@ export function serviceCommand(sub?: string): void {
|
|
|
336
589
|
}
|
|
337
590
|
switch (sub) {
|
|
338
591
|
case "install":
|
|
592
|
+
assertServiceEnvironmentMatchesInstall();
|
|
593
|
+
assertServiceAuthEnvironment();
|
|
339
594
|
ops.install();
|
|
340
595
|
console.log("✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
|
|
341
596
|
if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
|
|
@@ -345,23 +600,33 @@ export function serviceCommand(sub?: string): void {
|
|
|
345
600
|
console.log("✅ service started.");
|
|
346
601
|
break;
|
|
347
602
|
case "stop":
|
|
603
|
+
assertServiceEnvironmentMatchesInstall();
|
|
348
604
|
ops.stop();
|
|
605
|
+
stopTrackedProxyForServiceCommand();
|
|
349
606
|
restoreNativeCodex();
|
|
350
607
|
console.log("✅ service stopped + native Codex restored.");
|
|
351
608
|
break;
|
|
352
609
|
case "status": {
|
|
353
610
|
const s = ops.status();
|
|
354
611
|
console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
|
|
612
|
+
console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
|
|
355
613
|
break;
|
|
356
614
|
}
|
|
357
615
|
case "uninstall":
|
|
358
616
|
case "remove":
|
|
617
|
+
assertServiceEnvironmentMatchesInstall();
|
|
618
|
+
ops.stop();
|
|
619
|
+
stopTrackedProxyForServiceCommand();
|
|
359
620
|
ops.uninstall();
|
|
360
621
|
restoreNativeCodex();
|
|
622
|
+
for (const path of serviceStatePaths()) {
|
|
623
|
+
try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
|
|
624
|
+
}
|
|
625
|
+
try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
|
|
361
626
|
console.log("✅ service uninstalled + native Codex restored.");
|
|
362
627
|
break;
|
|
363
628
|
default:
|
|
364
|
-
console.error("Usage: ocx service <install|start|stop|status|uninstall>");
|
|
629
|
+
console.error("Usage: ocx service <install|start|stop|status|uninstall|remove>");
|
|
365
630
|
process.exit(1);
|
|
366
631
|
}
|
|
367
632
|
}
|
package/src/types.ts
CHANGED
|
@@ -123,6 +123,20 @@ export function namespacedToolName(namespace: string | undefined, name: string):
|
|
|
123
123
|
return namespace ? `${namespace}__${name}` : name;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
export function toolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
|
|
127
|
+
const wireName = namespacedToolName(tool.namespace, tool.name);
|
|
128
|
+
return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function toolAllowedByChoice(tool: Pick<OcxTool, "namespace" | "name">, allowedTools: ReadonlySet<string>): boolean {
|
|
132
|
+
return toolChoiceAliases(tool).some(name => allowedTools.has(name));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveToolChoiceWireName(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined, name: string): string {
|
|
136
|
+
const match = tools?.find(tool => toolChoiceAliases(tool).includes(name));
|
|
137
|
+
return match ? namespacedToolName(match.namespace, match.name) : name;
|
|
138
|
+
}
|
|
139
|
+
|
|
126
140
|
/**
|
|
127
141
|
* Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full
|
|
128
142
|
* id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers
|
|
@@ -135,12 +149,23 @@ export function modelInList(list: string[] | undefined, modelId: string): boolea
|
|
|
135
149
|
return colon > 0 && list.includes(modelId.slice(0, colon));
|
|
136
150
|
}
|
|
137
151
|
|
|
152
|
+
export type OcxToolChoice =
|
|
153
|
+
| "auto"
|
|
154
|
+
| "none"
|
|
155
|
+
| "required"
|
|
156
|
+
| { name: string }
|
|
157
|
+
| { allowedTools: string[]; mode: "auto" | "required" };
|
|
158
|
+
|
|
159
|
+
export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } {
|
|
160
|
+
return typeof value === "object" && value !== null && "allowedTools" in value;
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
export interface OcxRequestOptions {
|
|
139
164
|
maxOutputTokens?: number;
|
|
140
165
|
temperature?: number;
|
|
141
166
|
topP?: number;
|
|
142
167
|
stopSequences?: string[];
|
|
143
|
-
toolChoice?:
|
|
168
|
+
toolChoice?: OcxToolChoice;
|
|
144
169
|
reasoning?: string;
|
|
145
170
|
hideThinkingSummary?: boolean;
|
|
146
171
|
serviceTier?: string;
|
package/src/update.ts
CHANGED
|
@@ -22,36 +22,43 @@ function currentVersion(): string {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function updateTag(current: string): string {
|
|
26
|
+
const tagIndex = process.argv.indexOf("--tag");
|
|
27
|
+
if (tagIndex !== -1 && process.argv[tagIndex + 1]) return process.argv[tagIndex + 1];
|
|
28
|
+
return current.includes("-preview.") ? "preview" : "latest";
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
/** Latest published version from the registry (best-effort; null if npm isn't available). */
|
|
26
|
-
function latestVersion(): string | null {
|
|
27
|
-
const r = spawnSync("npm", ["view", PKG
|
|
32
|
+
function latestVersion(tag: string): string | null {
|
|
33
|
+
const r = spawnSync("npm", ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true });
|
|
28
34
|
return r.status === 0 ? r.stdout.trim() : null;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
|
-
* `ocx update`
|
|
33
|
-
*
|
|
38
|
+
* `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
|
|
39
|
+
* in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
|
|
34
40
|
*/
|
|
35
41
|
export async function runUpdate(): Promise<void> {
|
|
36
42
|
const installer = detectInstall();
|
|
37
43
|
const current = currentVersion();
|
|
38
|
-
|
|
44
|
+
const tag = updateTag(current);
|
|
45
|
+
console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`);
|
|
39
46
|
|
|
40
47
|
if (installer === "source") {
|
|
41
48
|
console.log("Running from a source checkout — update with: git pull && bun install");
|
|
42
49
|
return;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const latest = latestVersion();
|
|
52
|
+
const latest = latestVersion(tag);
|
|
46
53
|
if (latest && latest === current) {
|
|
47
|
-
console.log(`Already on the latest version (v${latest}).`);
|
|
54
|
+
console.log(`Already on the latest ${tag} version (v${latest}).`);
|
|
48
55
|
return;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
const bin = installer === "bun" ? "bun" : "npm";
|
|
52
59
|
const cmdArgs = installer === "bun"
|
|
53
|
-
? ["add", "-g", `${PKG}
|
|
54
|
-
: ["install", "-g", `${PKG}
|
|
60
|
+
? ["add", "-g", `${PKG}@${tag}`]
|
|
61
|
+
: ["install", "-g", `${PKG}@${tag}`];
|
|
55
62
|
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
|
|
56
63
|
|
|
57
64
|
const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });
|