@bitkyc08/opencodex 2.7.26 → 2.7.27
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.md +2 -2
- package/bin/ocx.mjs +23 -2
- package/gui/dist/assets/{index-BvQ5spEX.js → index-Vcr0pzdO.js} +3 -3
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/claude.ts +9 -0
- package/src/cli/index.ts +13 -1
- package/src/lib/service-secrets.ts +19 -0
- package/src/lib/winsw.ts +343 -0
- package/src/server/management-api.ts +19 -2
- package/src/server/system-env.ts +11 -0
- package/src/service.ts +203 -25
- package/src/update/index.ts +42 -3
- package/src/update/job.ts +12 -3
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-Vcr0pzdO.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-BnrJO9Wz.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
package/src/cli/claude.ts
CHANGED
|
@@ -65,6 +65,15 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
|
|
|
65
65
|
// Connectors still work because they check OAuth state ($o()), not base URL (Gd()).
|
|
66
66
|
// Native /model picker discovery ("From gateway", Claude Code >= 2.1.129).
|
|
67
67
|
setDefault("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
68
|
+
// Host-managed routing guard (devlog 260720_claude_authmode_persist/020): with
|
|
69
|
+
// this flag in the spawn env, Claude Code strips provider-managed vars
|
|
70
|
+
// (ANTHROPIC_BASE_URL/AUTH_TOKEN/API_KEY, model slots) from settings-sourced
|
|
71
|
+
// env (managedEnv.ts), so a leftover cc-switch/CCR ~/.claude/settings.json
|
|
72
|
+
// env block cannot silently hijack proxy routing away from opencodex.
|
|
73
|
+
// setDefault: an explicit user export (e.g. =0, isEnvTruthy-false) still wins.
|
|
74
|
+
// Intentional contract change: settings.env model slots are also stripped in
|
|
75
|
+
// ocx claude runs — use the top-level settings "model" field or opt out.
|
|
76
|
+
setDefault("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1");
|
|
68
77
|
// Opt-in effort forcing (devlog 136 B6): opus-shaped aliases already carry
|
|
69
78
|
// output_config.effort, so this is OFF unless the user enables it in config.
|
|
70
79
|
if (config.claudeCode?.alwaysEnableEffort === true) {
|
package/src/cli/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./h
|
|
|
25
25
|
import { findAvailablePort, isAddrInUse, shouldPersistSelectedPort } from "../server/ports";
|
|
26
26
|
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
27
27
|
import { stopProxy } from "../lib/process-control";
|
|
28
|
+
import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
28
29
|
import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
|
|
29
30
|
import { drainAndShutdown, startServer } from "../server";
|
|
30
31
|
import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
|
|
@@ -104,6 +105,11 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
async function handleStart(options: { block?: boolean } = {}) {
|
|
108
|
+
// Native (WinSW) service mode has no batch wrapper to read the service token file
|
|
109
|
+
// into the environment, so the app loads it here before the server binds. The server
|
|
110
|
+
// auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
|
|
111
|
+
const serviceToken = loadServiceTokenFromFile(process.env);
|
|
112
|
+
if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
|
|
107
113
|
const requestedPort = parsePortOption();
|
|
108
114
|
reconcileJournal();
|
|
109
115
|
const existingPid = readPid();
|
|
@@ -550,7 +556,7 @@ switch (command) {
|
|
|
550
556
|
break;
|
|
551
557
|
}
|
|
552
558
|
case "service":
|
|
553
|
-
await serviceCommand(args
|
|
559
|
+
await serviceCommand(...args.slice(1));
|
|
554
560
|
break;
|
|
555
561
|
case "codex-shim": {
|
|
556
562
|
const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("../codex/shim");
|
|
@@ -576,6 +582,12 @@ switch (command) {
|
|
|
576
582
|
break;
|
|
577
583
|
}
|
|
578
584
|
case "update": {
|
|
585
|
+
// `ocx update --help` must print usage and exit WITHOUT side effects — running the
|
|
586
|
+
// real self-update stops the proxy and drops in-flight routed streams (issue #168).
|
|
587
|
+
if (hasHelpFlag(args.slice(1))) {
|
|
588
|
+
printSubcommandUsage("update");
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
579
591
|
const { runUpdate } = await import("../update");
|
|
580
592
|
await runUpdate();
|
|
581
593
|
break;
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { getConfigDir } from "../config";
|
|
3
4
|
|
|
4
5
|
export function serviceApiTokenFilePath(): string {
|
|
5
6
|
return join(getConfigDir(), "service-api-token");
|
|
6
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* App-side service token loading (WinSW native mode has no batch wrapper to read the
|
|
11
|
+
* token file into the environment). Pure: returns the token or null — the CALLER
|
|
12
|
+
* assigns it to process.env.OPENCODEX_API_AUTH_TOKEN. Loads only when the env token
|
|
13
|
+
* is empty and OCX_API_TOKEN_FILE names a readable file.
|
|
14
|
+
*/
|
|
15
|
+
export function loadServiceTokenFromFile(env: Record<string, string | undefined>): string | null {
|
|
16
|
+
if (env.OPENCODEX_API_AUTH_TOKEN?.trim()) return null;
|
|
17
|
+
const file = env.OCX_API_TOKEN_FILE?.trim();
|
|
18
|
+
if (!file) return null;
|
|
19
|
+
try {
|
|
20
|
+
const token = readFileSync(file, "utf8").trim();
|
|
21
|
+
return token || null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/lib/winsw.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WinSW-backed native Windows service (opt-in via `ocx service install --native`).
|
|
3
|
+
*
|
|
4
|
+
* Design (devlog/_plan/260720_windows_service/060):
|
|
5
|
+
* - WinSW 2.12.0 NET461 build, downloaded on first native install and verified against
|
|
6
|
+
* a pinned SHA-256 (fail-closed: mismatch deletes the file and throws). The binary is
|
|
7
|
+
* NOT bundled in npm; offline installs get an explicit manual-placement hint.
|
|
8
|
+
* - Runs as the USER account (v2 `<serviceaccount>` domain/user/allowservicelogon —
|
|
9
|
+
* never LocalSystem: the ACL hardening in windows-secret-acl grants only the user SID,
|
|
10
|
+
* so a SYSTEM service could not read the token file, and SYSTEM-owned writes would
|
|
11
|
+
* change the user-access contract).
|
|
12
|
+
* - No password in XML: `winsw install /p` prompts on the console (stdin inherit).
|
|
13
|
+
* - Absolute paths only — no %USERPROFILE% indirection (that exists for OEM-codepage
|
|
14
|
+
* batch parsing; WinSW XML is Unicode).
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { expandUserPath, getConfigDir } from "../config";
|
|
22
|
+
import { durableBunPath } from "./bun-runtime";
|
|
23
|
+
import { serviceApiTokenFilePath } from "./service-secrets";
|
|
24
|
+
|
|
25
|
+
export const WINSW_VERSION = "2.12.0";
|
|
26
|
+
export const WINSW_URL = `https://github.com/winsw/winsw/releases/download/v${WINSW_VERSION}/WinSW.NET461.exe`;
|
|
27
|
+
/** SHA-256 of the official v2.12.0 WinSW.NET461.exe release asset (655872 bytes). */
|
|
28
|
+
export const WINSW_SHA256 = "b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f";
|
|
29
|
+
|
|
30
|
+
/** SCM service id — distinct from the Task Scheduler task name (opencodex-proxy). */
|
|
31
|
+
export const WINSW_SERVICE_ID = "opencodex-proxy-native";
|
|
32
|
+
|
|
33
|
+
export function winswDir(): string {
|
|
34
|
+
return join(getConfigDir(), "winsw");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** WinSW discovers its config as the same-basename XML next to the exe. */
|
|
38
|
+
export function winswExePath(): string {
|
|
39
|
+
return join(winswDir(), `${WINSW_SERVICE_ID}.exe`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function winswXmlPath(): string {
|
|
43
|
+
return join(winswDir(), `${WINSW_SERVICE_ID}.xml`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function winswLogDir(): string {
|
|
47
|
+
return getConfigDir();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function xmlEscape(value: string): string {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, "&")
|
|
53
|
+
.replace(/</g, "<")
|
|
54
|
+
.replace(/>/g, ">")
|
|
55
|
+
.replace(/"/g, """)
|
|
56
|
+
.replace(/'/g, "'");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function currentCodexHomeAbsolute(): string {
|
|
60
|
+
const raw = process.env.CODEX_HOME?.trim();
|
|
61
|
+
return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WinswEntry {
|
|
65
|
+
bun: string;
|
|
66
|
+
cli: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the WinSW v2 XML. Never embeds the API token value — the app loads it from
|
|
71
|
+
* OCX_API_TOKEN_FILE at startup (cli handleStart). PATH is baked for parity with the
|
|
72
|
+
* Task Scheduler wrapper / launchd / systemd: the SCM service environment lacks the
|
|
73
|
+
* user's interactive PATH, which provider subprocesses may need.
|
|
74
|
+
*/
|
|
75
|
+
export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env): string {
|
|
76
|
+
const domain = env.USERDOMAIN?.trim() || ".";
|
|
77
|
+
const user = env.USERNAME?.trim() || "";
|
|
78
|
+
const envLines = [
|
|
79
|
+
` <env name="OCX_SERVICE" value="1"/>`,
|
|
80
|
+
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
|
|
81
|
+
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
|
|
82
|
+
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
|
|
83
|
+
env.OPENCODEX_HOME?.trim() ? ` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>` : null,
|
|
84
|
+
].filter((line): line is string => Boolean(line));
|
|
85
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
|
+
<service>
|
|
87
|
+
<id>${WINSW_SERVICE_ID}</id>
|
|
88
|
+
<name>OpenCodex Proxy (native)</name>
|
|
89
|
+
<description>OpenCodex proxy running as a native Windows service (windowless, starts at boot).</description>
|
|
90
|
+
<executable>${xmlEscape(entry.bun)}</executable>
|
|
91
|
+
<arguments>${xmlEscape(`"${entry.cli}" start`)}</arguments>
|
|
92
|
+
${envLines.join("\n")}
|
|
93
|
+
<logpath>${xmlEscape(winswLogDir())}</logpath>
|
|
94
|
+
<log mode="roll-by-size">
|
|
95
|
+
<sizeThreshold>10240</sizeThreshold>
|
|
96
|
+
<keepFiles>4</keepFiles>
|
|
97
|
+
</log>
|
|
98
|
+
<onfailure action="restart" delay="5 sec"/>
|
|
99
|
+
<stoptimeout>20 sec</stoptimeout>
|
|
100
|
+
<serviceaccount>
|
|
101
|
+
<domain>${xmlEscape(domain)}</domain>
|
|
102
|
+
<user>${xmlEscape(user)}</user>
|
|
103
|
+
<allowservicelogon>true</allowservicelogon>
|
|
104
|
+
</serviceaccount>
|
|
105
|
+
</service>
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function sha256Hex(data: Uint8Array | Buffer): string {
|
|
110
|
+
return createHash("sha256").update(data).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ensure the pinned WinSW binary exists locally; download + verify on first use.
|
|
115
|
+
* Fail-closed: any hash mismatch deletes the file and throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function ensureWinswBinary(fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
118
|
+
const exe = winswExePath();
|
|
119
|
+
if (existsSync(exe)) {
|
|
120
|
+
const digest = sha256Hex(readFileSync(exe));
|
|
121
|
+
if (digest === WINSW_SHA256) return exe;
|
|
122
|
+
unlinkSync(exe);
|
|
123
|
+
console.warn("⚠️ Existing WinSW binary failed hash verification; re-downloading.");
|
|
124
|
+
}
|
|
125
|
+
if (!existsSync(winswDir())) mkdirSync(winswDir(), { recursive: true });
|
|
126
|
+
let body: ArrayBuffer;
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetchImpl(WINSW_URL);
|
|
129
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
130
|
+
body = await res.arrayBuffer();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Failed to download WinSW ${WINSW_VERSION} (${err instanceof Error ? err.message : String(err)}). ` +
|
|
134
|
+
`Offline? Place the official WinSW.NET461.exe (v${WINSW_VERSION}) at ${exe} and retry.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const bytes = Buffer.from(body);
|
|
138
|
+
const digest = sha256Hex(bytes);
|
|
139
|
+
if (digest !== WINSW_SHA256) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`WinSW download failed SHA-256 verification (got ${digest}, expected ${WINSW_SHA256}). ` +
|
|
142
|
+
"Refusing to install an unverified service binary.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(exe, bytes);
|
|
146
|
+
return exe;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runWinsw(args: string[]): string {
|
|
150
|
+
return execFileSync(winswExePath(), args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
|
|
154
|
+
function runWinswInteractive(args: string[]): void {
|
|
155
|
+
execFileSync(winswExePath(), args, { stdio: "inherit" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function scQc(): string {
|
|
159
|
+
const sc = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "sc.exe");
|
|
160
|
+
return execFileSync(existsSync(sc) ? sc : "sc.exe", ["qc", WINSW_SERVICE_ID], {
|
|
161
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type WinswStatus = "started" | "stopped" | "nonexistent" | "unknown";
|
|
166
|
+
|
|
167
|
+
/** WinSW v2 `status` prints exactly Started / Stopped / NonExistent. */
|
|
168
|
+
export function parseWinswStatus(output: string): WinswStatus {
|
|
169
|
+
const normalized = output.trim().toLowerCase();
|
|
170
|
+
if (normalized.includes("nonexistent")) return "nonexistent";
|
|
171
|
+
if (normalized.includes("started")) return "started";
|
|
172
|
+
if (normalized.includes("stopped")) return "stopped";
|
|
173
|
+
// Anything else is an unparseable result — NOT proof of absence. Callers must
|
|
174
|
+
// fail closed: only an exact NonExistent may skip stop/uninstall.
|
|
175
|
+
return "unknown";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function statusWinswRaw(): WinswStatus {
|
|
179
|
+
if (existsSync(winswExePath())) {
|
|
180
|
+
try {
|
|
181
|
+
return parseWinswStatus(runWinsw(["status"]));
|
|
182
|
+
} catch {
|
|
183
|
+
// The query itself failed (access denied, damaged/quarantined exe, ...). Treat the
|
|
184
|
+
// service as possibly-installed so lifecycle operations still attempt stop/uninstall
|
|
185
|
+
// instead of skipping a live SCM service that would keep respawning the proxy.
|
|
186
|
+
return "unknown";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// A missing exe does NOT prove the SCM registration is gone (quarantined binary,
|
|
190
|
+
// partial uninstall): a stale opencodex-proxy-native registration can outlive it.
|
|
191
|
+
// Confirm absence against the SCM itself before reporting "nonexistent".
|
|
192
|
+
if (process.platform !== "win32") return "nonexistent";
|
|
193
|
+
const probe = probeScmRegistration();
|
|
194
|
+
// probe === "error": the SCM could not be queried — fail closed, never claim absence.
|
|
195
|
+
return probe === false ? "nonexistent" : "unknown";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Probe the SCM for the native service registration.
|
|
200
|
+
* Returns true (registered), false (confirmed absent — exit 1060 only), or "error"
|
|
201
|
+
* (query itself failed: access denied, sc.exe missing, ...). Only a confirmed
|
|
202
|
+
* ERROR_SERVICE_DOES_NOT_EXIST may prove absence to lifecycle callers.
|
|
203
|
+
*/
|
|
204
|
+
export function probeScmRegistration(run: () => string = queryScmForService): boolean | "error" {
|
|
205
|
+
try {
|
|
206
|
+
run();
|
|
207
|
+
return true;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const e = err as { status?: number | null; stderr?: string | Buffer | null; stdout?: string | Buffer | null; message?: string };
|
|
210
|
+
// sc.exe does not reliably channel the 1060 line — it can land on stderr OR stdout
|
|
211
|
+
// depending on the host — so scan every captured stream and the error message.
|
|
212
|
+
const text = [e.stderr, e.stdout, e.message]
|
|
213
|
+
.map(v => (typeof v === "string" ? v : ""))
|
|
214
|
+
.join("\n");
|
|
215
|
+
if (e.status === 1060 || /FAILED 1060/i.test(text)) return false;
|
|
216
|
+
return "error";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function scExePath(): string {
|
|
221
|
+
const sc = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "sc.exe");
|
|
222
|
+
return existsSync(sc) ? sc : "sc.exe";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function queryScmForService(): string {
|
|
226
|
+
return execFileSync(scExePath(), ["query", WINSW_SERVICE_ID], {
|
|
227
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Verify the installed SCM service runs as the intended user, not LocalSystem (WinSW's
|
|
233
|
+
* default when the XML account section is ignored/malformed). Rolls back on mismatch.
|
|
234
|
+
*/
|
|
235
|
+
function assertServiceAccountApplied(env: NodeJS.ProcessEnv = process.env): void {
|
|
236
|
+
const qc = scQc();
|
|
237
|
+
const startName = /SERVICE_START_NAME\s*:\s*(.+)/i.exec(qc)?.[1]?.trim() ?? "";
|
|
238
|
+
const user = env.USERNAME?.trim() ?? "";
|
|
239
|
+
if (/localsystem/i.test(startName) || (user && !startName.toLowerCase().includes(user.toLowerCase()))) {
|
|
240
|
+
try { runWinsw(["uninstall"]); } catch { /* rollback is best-effort */ }
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Native service was registered as "${startName || "unknown"}" instead of the current user; ` +
|
|
243
|
+
"rolled back. Re-run `ocx service install --native` and enter the account credentials when prompted.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface WinswInstallDeps {
|
|
249
|
+
ensureBinary?: () => Promise<string>;
|
|
250
|
+
writeXml?: (path: string, content: string) => void;
|
|
251
|
+
interactive?: (args: string[]) => void;
|
|
252
|
+
run?: (args: string[]) => string;
|
|
253
|
+
verifyAccount?: () => void;
|
|
254
|
+
status?: () => WinswStatus;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Install (or repair) the native service. Re-running against an existing service skips
|
|
259
|
+
* `install /p` — assets are rewritten and the service restarted without re-prompting
|
|
260
|
+
* credentials (WinSW `install` fails with "service already exists").
|
|
261
|
+
*/
|
|
262
|
+
export async function installWinswService(entry: WinswEntry, deps: WinswInstallDeps = {}): Promise<void> {
|
|
263
|
+
const ensureBinary = deps.ensureBinary ?? ensureWinswBinary;
|
|
264
|
+
const writeXml = deps.writeXml ?? ((path: string, content: string) => writeFileSync(path, content, "utf8"));
|
|
265
|
+
const interactive = deps.interactive ?? runWinswInteractive;
|
|
266
|
+
const run = deps.run ?? runWinsw;
|
|
267
|
+
const verifyAccount = deps.verifyAccount ?? assertServiceAccountApplied;
|
|
268
|
+
const status = deps.status ?? statusWinswRaw;
|
|
269
|
+
|
|
270
|
+
await ensureBinary();
|
|
271
|
+
writeXml(winswXmlPath(), buildWinswXml(entry));
|
|
272
|
+
const existing = status();
|
|
273
|
+
if (existing === "unknown") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Could not query the native service state (WinSW status failed or returned an unexpected result). " +
|
|
276
|
+
"Refusing to guess the install state — check 'ocx service status' and retry.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (existing === "nonexistent") {
|
|
280
|
+
// WinSW self-elevates via UAC; a refused prompt aborts install (no silent fallback).
|
|
281
|
+
// v2.12 recognizes prompting only as args[1]: `install /p` (XML is auto-discovered
|
|
282
|
+
// as the same-basename file next to the exe).
|
|
283
|
+
interactive(["install", "/p"]);
|
|
284
|
+
verifyAccount();
|
|
285
|
+
} else {
|
|
286
|
+
// Use `stopwait` (not `stop`) so the SCM service fully stops before `start` — bare
|
|
287
|
+
// `stop` only sends the stop request; `start` against a STOP_PENDING service fails.
|
|
288
|
+
try { run(["stopwait"]); } catch { /* already stopped */ }
|
|
289
|
+
}
|
|
290
|
+
run(["start"]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function startWinswService(): void { runWinsw(["start"]); }
|
|
294
|
+
export function stopWinswService(): void { try { runWinsw(["stopwait"]); } catch { /* not running */ } }
|
|
295
|
+
export function uninstallWinswService(): void {
|
|
296
|
+
if (!existsSync(winswExePath())) {
|
|
297
|
+
// The binary is gone but the SCM registration can outlive it (quarantine, partial
|
|
298
|
+
// uninstall). WinSW can't run without its exe, so remove the stale registration
|
|
299
|
+
// directly via sc.exe — otherwise the SCM service survives every cleanup path.
|
|
300
|
+
if (process.platform === "win32") {
|
|
301
|
+
const probe = probeScmRegistration();
|
|
302
|
+
if (probe === "error") {
|
|
303
|
+
// Presence unknown — fail closed: keep service state, surface the failure
|
|
304
|
+
// instead of reporting a clean uninstall over a possibly-live registration.
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Cannot verify the native service registration (sc.exe query failed). ` +
|
|
307
|
+
`Uninstall aborted; check 'sc query ${WINSW_SERVICE_ID}' and retry.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (probe === true) {
|
|
311
|
+
try {
|
|
312
|
+
execFileSync(scExePath(), ["stop", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
313
|
+
} catch { /* not running */ }
|
|
314
|
+
execFileSync(scExePath(), ["delete", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try { runWinsw(["stopwait"]); } catch { /* not running */ }
|
|
320
|
+
try { runWinsw(["uninstall"]); } catch (err) {
|
|
321
|
+
// Surface the failure so the caller can decide; silent swallow hides UAC refusals.
|
|
322
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
323
|
+
if (!msg.toLowerCase().includes("nonexistent")) throw new Error(`WinSW uninstall failed: ${msg}`);
|
|
324
|
+
// "NonExistent" means already absent — that's fine.
|
|
325
|
+
}
|
|
326
|
+
// exe/xml intentionally retained for credential-free reinstall; `--purge` is out of scope.
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function winswStatusSummary(): string {
|
|
330
|
+
const status = statusWinswRaw();
|
|
331
|
+
if (status === "nonexistent") {
|
|
332
|
+
// A stale SCM service can outlive a deleted exe; surface the repair path.
|
|
333
|
+
return existsSync(winswXmlPath()) && !existsSync(winswExePath())
|
|
334
|
+
? "native assets present but WinSW binary missing — run 'ocx service install --native' to repair"
|
|
335
|
+
: "";
|
|
336
|
+
}
|
|
337
|
+
return `native (WinSW ${WINSW_VERSION}): ${status}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
|
|
341
|
+
export function defaultWinswEntry(cliDir: string): WinswEntry {
|
|
342
|
+
return { bun: durableBunPath(), cli: join(cliDir, "cli", "index.ts") };
|
|
343
|
+
}
|
|
@@ -992,6 +992,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
992
992
|
const visionOverride = config.claudeCode?.visionSidecar;
|
|
993
993
|
return jsonResponse({
|
|
994
994
|
enabled: config.claudeCode?.enabled !== false,
|
|
995
|
+
// Round-trip contract with the GUI auth-mode select (devlog 260720_claude_authmode_persist):
|
|
996
|
+
// absent config key = subscription (OcxClaudeCodeConfig.authMode is typed `"proxy"` only).
|
|
997
|
+
authMode: config.claudeCode?.authMode === "proxy" ? "proxy" : "subscription",
|
|
995
998
|
model: config.claudeCode?.model ?? "",
|
|
996
999
|
smallFastModel: config.claudeCode?.smallFastModel ?? "",
|
|
997
1000
|
tierModels: config.claudeCode?.tierModels ?? {},
|
|
@@ -1032,7 +1035,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1032
1035
|
return prototype === Object.prototype || prototype === null;
|
|
1033
1036
|
};
|
|
1034
1037
|
if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
1035
|
-
const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
|
|
1038
|
+
const body = parsedBody as { enabled?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
|
|
1036
1039
|
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
1037
1040
|
const section = body[field];
|
|
1038
1041
|
if (section === undefined || section === null) continue;
|
|
@@ -1066,6 +1069,17 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1066
1069
|
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
1067
1070
|
next.enabled = body.enabled;
|
|
1068
1071
|
}
|
|
1072
|
+
if (body.authMode !== undefined) {
|
|
1073
|
+
// "proxy" stores the key; "subscription" (the default) deletes it —
|
|
1074
|
+
// OcxClaudeCodeConfig.authMode is typed `"proxy"` only (src/types.ts).
|
|
1075
|
+
// Previously this field was silently dropped, so the GUI select reverted to
|
|
1076
|
+
// Subscription on every reload (devlog 260720_claude_authmode_persist).
|
|
1077
|
+
if (body.authMode !== "proxy" && body.authMode !== "subscription") {
|
|
1078
|
+
return jsonResponse({ error: "authMode must be \"proxy\" or \"subscription\"" }, 400);
|
|
1079
|
+
}
|
|
1080
|
+
if (body.authMode === "proxy") next.authMode = "proxy";
|
|
1081
|
+
else delete next.authMode;
|
|
1082
|
+
}
|
|
1069
1083
|
if (body.systemEnv !== undefined) {
|
|
1070
1084
|
if (typeof body.systemEnv !== "boolean") return jsonResponse({ error: "systemEnv must be a boolean" }, 400);
|
|
1071
1085
|
next.systemEnv = body.systemEnv;
|
|
@@ -1175,7 +1189,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1175
1189
|
const { saveConfig: save } = await import("../config");
|
|
1176
1190
|
save(config);
|
|
1177
1191
|
const warnings: string[] = [];
|
|
1178
|
-
|
|
1192
|
+
// authMode changes must reconcile the injected system env too: switching back to
|
|
1193
|
+
// Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN
|
|
1194
|
+
// (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist).
|
|
1195
|
+
if (body.systemEnv !== undefined || body.authMode !== undefined) {
|
|
1179
1196
|
try {
|
|
1180
1197
|
await applySystemEnvToggle(config, config.port);
|
|
1181
1198
|
} catch (err) {
|
package/src/server/system-env.ts
CHANGED
|
@@ -242,6 +242,17 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
|
|
|
242
242
|
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
243
243
|
} else if (config.claudeCode?.authMode === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
|
|
244
244
|
inject("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy");
|
|
245
|
+
} else if (config.claudeCode?.authMode !== "proxy"
|
|
246
|
+
&& injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")
|
|
247
|
+
&& launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === "opencodex-proxy") {
|
|
248
|
+
// Subscription switch-back (devlog 260720_claude_authmode_persist): remove ONLY
|
|
249
|
+
// the opencodex-owned dummy token so a launchd-started Claude regains its own
|
|
250
|
+
// claude.ai OAuth. User-set tokens (not tracked in injectedKeys, or carrying a
|
|
251
|
+
// different value) are never touched.
|
|
252
|
+
unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
|
|
253
|
+
const dummyIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
|
|
254
|
+
if (dummyIdx >= 0) injectedKeys.splice(dummyIdx, 1);
|
|
255
|
+
writeTracking(port, injectedKeys);
|
|
245
256
|
}
|
|
246
257
|
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
247
258
|
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|