@bitkyc08/opencodex 2.7.43 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +10 -3
- package/src/lib/provider-outbound.ts +5 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +57 -8
- package/src/oauth/kiro.ts +2 -1
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +78 -4
- package/src/server/management/config-routes.ts +19 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +237 -19
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +32 -4
- package/src/types.ts +11 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream connection failures share one message shape across the three catch sites in
|
|
3
|
+
* core.ts. A TLS certificate/hostname mismatch deserves its own wording: the generic
|
|
4
|
+
* "Provider unreachable" reads as if opencodex built a wrong endpoint, which sent issue
|
|
5
|
+
* #553 looking for an adapter URL bug that does not exist. Name the likely cause and the
|
|
6
|
+
* command that settles it.
|
|
7
|
+
*/
|
|
8
|
+
export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string {
|
|
9
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
10
|
+
return `Provider connect timeout after ${connectMs}ms`;
|
|
11
|
+
}
|
|
12
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
13
|
+
const code = err instanceof Error ? (err as { code?: unknown }).code : undefined;
|
|
14
|
+
// `code` is the reliable signal. The message fallback is anchored to the head because Bun
|
|
15
|
+
// renders this rejection as `ERR_TLS_CERT_ALTNAME_INVALID fetching "<url>"`; matching the
|
|
16
|
+
// bare substring anywhere would also fire on text that merely quotes the code back at us.
|
|
17
|
+
// Only transport failures reach these call sites, so that is defensive rather than load-bearing.
|
|
18
|
+
if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || detail.startsWith("ERR_TLS_CERT_ALTNAME_INVALID")) {
|
|
19
|
+
const host = extractHostname(detail);
|
|
20
|
+
const target = host ?? "the provider host";
|
|
21
|
+
const probe = host ?? "<host>";
|
|
22
|
+
return `Provider TLS certificate does not match ${target}: ${redactUrlUserinfo(detail)}. `
|
|
23
|
+
+ "opencodex did not rewrite this hostname — a certificate that does not cover it normally "
|
|
24
|
+
+ "means TLS interception (corporate proxy, VPN, or local MITM tooling) or a poisoned DNS "
|
|
25
|
+
+ `answer. Check with: openssl s_client -connect ${probe}:443 -servername ${probe} `
|
|
26
|
+
+ "</dev/null | openssl x509 -noout -subject -ext subjectAltName";
|
|
27
|
+
}
|
|
28
|
+
return `Provider unreachable: ${redactUrlUserinfo(detail)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A provider base URL may carry credentials as userinfo (`https://user:token@host/`), and the
|
|
33
|
+
* runtime error echoes the URL it was fetching. Strip it before the message reaches a client
|
|
34
|
+
* or a log line.
|
|
35
|
+
*/
|
|
36
|
+
function redactUrlUserinfo(detail: string): string {
|
|
37
|
+
return detail.replace(/(https?:\/\/)[^/\s"'@]*@/g, "$1<redacted>@");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractHostname(detail: string): string | null {
|
|
41
|
+
const match = detail.match(/https?:\/\/([^/\s"']+)/);
|
|
42
|
+
if (!match?.[1]) return null;
|
|
43
|
+
try {
|
|
44
|
+
return new URL(`https://${match[1]}`).hostname || null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -106,10 +106,14 @@ export function resetStartupInstallStateForTests(): void {
|
|
|
106
106
|
installState = { status: "idle" };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
export function startupInstallArgv(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
export function startupInstallArgv(
|
|
110
|
+
action: StartupInstallAction,
|
|
111
|
+
options?: { repair?: boolean },
|
|
112
|
+
): string[] {
|
|
113
|
+
if (action === "install-service") {
|
|
114
|
+
return options?.repair ? ["service", "repair"] : ["service", "install"];
|
|
115
|
+
}
|
|
116
|
+
return ["codex-shim", "install"];
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
export interface CliInstallFailure {
|
|
@@ -152,10 +156,13 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
|
|
|
152
156
|
return classifyCliInstallFailure(stdout, stderr, error).detail;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
function runCliInstall(
|
|
159
|
+
function runCliInstall(
|
|
160
|
+
action: StartupInstallAction,
|
|
161
|
+
options?: { repair?: boolean },
|
|
162
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
156
163
|
const bun = durableBunPath();
|
|
157
164
|
const cli = join(import.meta.dir, "..", "cli", "index.ts");
|
|
158
|
-
const argv = [cli, ...startupInstallArgv(action)];
|
|
165
|
+
const argv = [cli, ...startupInstallArgv(action, options)];
|
|
159
166
|
return new Promise((resolve, reject) => {
|
|
160
167
|
execFile(bun, argv, {
|
|
161
168
|
encoding: "utf8",
|
|
@@ -219,29 +226,37 @@ function applyReconciliationOutcome(
|
|
|
219
226
|
/**
|
|
220
227
|
* Execute the existing fixed CLI installer outside the proxy event loop.
|
|
221
228
|
*
|
|
229
|
+
* Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart
|
|
230
|
+
* without Task Scheduler re-registration, so it must not enter the UAC elevation path.
|
|
231
|
+
*
|
|
222
232
|
* After an elevation request timeout the lock becomes `indeterminate` until the
|
|
223
233
|
* original elevated transaction completes and is reconciled. A process restart
|
|
224
234
|
* clears this in-memory lock — callers must then inspect Task Scheduler reality
|
|
225
235
|
* (see evaluateSchedulerInstallRestartReconciliation) before installing again.
|
|
226
236
|
*/
|
|
227
|
-
export function runStartupInstallAction(
|
|
237
|
+
export function runStartupInstallAction(
|
|
238
|
+
action: StartupInstallAction,
|
|
239
|
+
options?: { repair?: boolean },
|
|
240
|
+
): Promise<{ message: string }> {
|
|
228
241
|
const busy = rejectIfBusy(action);
|
|
229
242
|
if (busy) return Promise.reject(busy);
|
|
230
243
|
|
|
244
|
+
const repair = options?.repair === true;
|
|
231
245
|
const attemptId = randomUUID();
|
|
232
246
|
const startedAt = Date.now();
|
|
233
247
|
installState = { status: "running", action, attemptId, startedAt };
|
|
234
248
|
|
|
235
249
|
const operation = (async () => {
|
|
236
250
|
try {
|
|
237
|
-
await runCliInstall(action);
|
|
251
|
+
await runCliInstall(action, { repair });
|
|
238
252
|
} catch (error) {
|
|
239
253
|
const code = installFailureCode(error);
|
|
240
254
|
const detail = error instanceof Error ? error.message : String(error);
|
|
241
|
-
// Elevate only for
|
|
242
|
-
// WinSW removal, asset writes, or generic permission errors.
|
|
255
|
+
// Elevate only for fresh install + structured Task Scheduler /create access denial —
|
|
256
|
+
// never for repair, WinSW removal, asset writes, or generic permission errors.
|
|
243
257
|
if (
|
|
244
|
-
|
|
258
|
+
!repair
|
|
259
|
+
&& action === "install-service"
|
|
245
260
|
&& process.platform === "win32"
|
|
246
261
|
&& (code === WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER
|
|
247
262
|
|| isWindowsSchtasksCreateAccessDenied(detail))
|
|
@@ -276,10 +291,11 @@ export function runStartupInstallAction(action: StartupInstallAction): Promise<{
|
|
|
276
291
|
throw error;
|
|
277
292
|
}
|
|
278
293
|
}
|
|
294
|
+
if (action === "install-service") {
|
|
295
|
+
return { message: repair ? "Background service repaired." : "Background service installed." };
|
|
296
|
+
}
|
|
279
297
|
return {
|
|
280
|
-
message:
|
|
281
|
-
? "Background service installed."
|
|
282
|
-
: "Codex launcher shim installed.",
|
|
298
|
+
message: repair ? "Codex launcher shim repaired." : "Codex launcher shim installed.",
|
|
283
299
|
};
|
|
284
300
|
})();
|
|
285
301
|
|
package/src/service.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { isWslRuntime } from "./codex/home";
|
|
|
17
17
|
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
|
|
18
18
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
19
19
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
20
|
+
import { findLiveProxy } from "./server/proxy-liveness";
|
|
20
21
|
import { randomUUID } from "node:crypto";
|
|
21
22
|
import {
|
|
22
23
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
@@ -35,6 +36,7 @@ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswSer
|
|
|
35
36
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
36
37
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
37
38
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
39
|
+
import { maybeShowStarPrompt } from "./cli/star-prompt";
|
|
38
40
|
|
|
39
41
|
const LABEL = "com.opencodex.proxy";
|
|
40
42
|
const TASK = "opencodex-proxy";
|
|
@@ -354,8 +356,41 @@ function sh(cmd: string): string {
|
|
|
354
356
|
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
355
357
|
}
|
|
356
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
|
|
361
|
+
* registered task document is UTF-16; reading that as UTF-8 makes every health check
|
|
362
|
+
* fail ("registration present but unhealthy") and rolls back a successful elevated create.
|
|
363
|
+
*/
|
|
364
|
+
export function decodeSchtasksOutput(buffer: Buffer): string {
|
|
365
|
+
if (buffer.length === 0) return "";
|
|
366
|
+
const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
|
|
367
|
+
const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
|
|
368
|
+
const looksUtf16Le = buffer.length >= 4
|
|
369
|
+
&& buffer[1] === 0x00
|
|
370
|
+
&& buffer[3] === 0x00
|
|
371
|
+
&& buffer[0] !== 0x00;
|
|
372
|
+
if (bomUtf16Le || looksUtf16Le) {
|
|
373
|
+
return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
|
|
374
|
+
}
|
|
375
|
+
if (bomUtf16Be) {
|
|
376
|
+
// Swap pairs then decode as utf16le.
|
|
377
|
+
const swapped = Buffer.alloc(buffer.length - 2);
|
|
378
|
+
for (let i = 2; i + 1 < buffer.length; i += 2) {
|
|
379
|
+
swapped[i - 2] = buffer[i + 1]!;
|
|
380
|
+
swapped[i - 1] = buffer[i]!;
|
|
381
|
+
}
|
|
382
|
+
return swapped.toString("utf16le").trim();
|
|
383
|
+
}
|
|
384
|
+
return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
385
|
+
}
|
|
386
|
+
|
|
357
387
|
function runFile(file: string, args: string[]): string {
|
|
358
|
-
|
|
388
|
+
const buffer = execFileSync(file, args, {
|
|
389
|
+
encoding: "buffer",
|
|
390
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
391
|
+
windowsHide: true,
|
|
392
|
+
}) as Buffer;
|
|
393
|
+
return decodeSchtasksOutput(buffer);
|
|
359
394
|
}
|
|
360
395
|
|
|
361
396
|
function windowsSchtasks(): string {
|
|
@@ -393,6 +428,70 @@ export type WindowsSchedulerTaskProbe =
|
|
|
393
428
|
| { status: "absent" }
|
|
394
429
|
| { status: "unknown"; detail: string };
|
|
395
430
|
|
|
431
|
+
export type WindowsSchedulerProxyProbe =
|
|
432
|
+
| { status: "running"; port: number }
|
|
433
|
+
| { status: "not-running" }
|
|
434
|
+
| { status: "unknown" };
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Render Task Scheduler status without exposing localized `schtasks` table output.
|
|
438
|
+
* The task probe answers installation state; the identity-checked health probe answers
|
|
439
|
+
* runtime state. Keep probe details out of this user-facing line because they can contain
|
|
440
|
+
* incorrectly decoded, locale-specific command output.
|
|
441
|
+
*/
|
|
442
|
+
export function formatWindowsSchedulerServiceStatus(
|
|
443
|
+
task: WindowsSchedulerTaskProbe,
|
|
444
|
+
proxy: WindowsSchedulerProxyProbe,
|
|
445
|
+
): string {
|
|
446
|
+
if (task.status === "present") {
|
|
447
|
+
if (proxy.status === "running") {
|
|
448
|
+
return `✅ service installed (Task Scheduler); OpenCodex proxy running on port ${proxy.port}.`;
|
|
449
|
+
}
|
|
450
|
+
if (proxy.status === "not-running") {
|
|
451
|
+
return "⚠️ service installed (Task Scheduler); OpenCodex proxy not running.";
|
|
452
|
+
}
|
|
453
|
+
return "⚠️ service installed (Task Scheduler); OpenCodex proxy status unknown.";
|
|
454
|
+
}
|
|
455
|
+
if (task.status === "absent") {
|
|
456
|
+
if (proxy.status === "running") {
|
|
457
|
+
return `❌ service not installed (Task Scheduler); OpenCodex proxy is running independently on port ${proxy.port}.`;
|
|
458
|
+
}
|
|
459
|
+
if (proxy.status === "unknown") {
|
|
460
|
+
return "❌ service not installed (Task Scheduler); OpenCodex proxy status unknown.";
|
|
461
|
+
}
|
|
462
|
+
return "❌ service not installed (Task Scheduler).";
|
|
463
|
+
}
|
|
464
|
+
if (proxy.status === "running") {
|
|
465
|
+
return `⚠️ Task Scheduler registration unknown; OpenCodex proxy running on port ${proxy.port}.`;
|
|
466
|
+
}
|
|
467
|
+
if (proxy.status === "not-running") {
|
|
468
|
+
return "⚠️ service status unknown (Task Scheduler query failed); OpenCodex proxy not running.";
|
|
469
|
+
}
|
|
470
|
+
return "⚠️ service status unknown (Task Scheduler and proxy checks failed).";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function inspectWindowsSchedulerServiceStatus(io: {
|
|
474
|
+
probeTask?: () => WindowsSchedulerTaskProbe;
|
|
475
|
+
findProxy?: () => Promise<{ port: number } | null>;
|
|
476
|
+
} = {}): Promise<string> {
|
|
477
|
+
let task: WindowsSchedulerTaskProbe;
|
|
478
|
+
try {
|
|
479
|
+
task = (io.probeTask ?? probeWindowsSchedulerTask)();
|
|
480
|
+
} catch (error) {
|
|
481
|
+
task = { status: "unknown", detail: schtasksErrorDetail(error) };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let proxy: WindowsSchedulerProxyProbe;
|
|
485
|
+
try {
|
|
486
|
+
const live = await (io.findProxy ?? findLiveProxy)();
|
|
487
|
+
proxy = live ? { status: "running", port: live.port } : { status: "not-running" };
|
|
488
|
+
} catch {
|
|
489
|
+
proxy = { status: "unknown" };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return formatWindowsSchedulerServiceStatus(task, proxy);
|
|
493
|
+
}
|
|
494
|
+
|
|
396
495
|
function schtasksErrorDetail(error: unknown): string {
|
|
397
496
|
return error instanceof Error ? error.message : String(error);
|
|
398
497
|
}
|
|
@@ -486,7 +585,9 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
486
585
|
: !assetsHealthy
|
|
487
586
|
? "Required scheduler service assets are missing."
|
|
488
587
|
: !registrationHealthy
|
|
489
|
-
?
|
|
588
|
+
? (inputs.xml.trim()
|
|
589
|
+
? "Task Scheduler registration is present but unhealthy."
|
|
590
|
+
: "Task Scheduler task is present but its XML could not be read.")
|
|
490
591
|
: nativeStatusUnknown
|
|
491
592
|
? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent."
|
|
492
593
|
: "ok";
|
|
@@ -505,9 +606,18 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
505
606
|
/** Conflict-free postcondition check for an elevated scheduler install. */
|
|
506
607
|
export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification {
|
|
507
608
|
const taskInstalled = windowsSchedulerTaskInstalled(taskName);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
609
|
+
let xml = "";
|
|
610
|
+
if (taskInstalled) {
|
|
611
|
+
try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; }
|
|
612
|
+
}
|
|
613
|
+
// After elevated create, non-elevated `/query /xml` can fail or return empty while the
|
|
614
|
+
// task is still listed. Fall back to the on-disk document we registered.
|
|
615
|
+
if (taskInstalled && !xml.trim()) {
|
|
616
|
+
const diskPath = windowsTaskXmlPath();
|
|
617
|
+
if (existsSync(diskPath)) {
|
|
618
|
+
try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
511
621
|
return evaluateWindowsSchedulerInstallVerification({
|
|
512
622
|
taskInstalled,
|
|
513
623
|
xml,
|
|
@@ -895,6 +1005,22 @@ function taskXmlString(value: string): string {
|
|
|
895
1005
|
.replace(/'/g, "'");
|
|
896
1006
|
}
|
|
897
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
|
|
1010
|
+
* `schtasks /create` often rewrites the registered task to HighestAvailable even when
|
|
1011
|
+
* the source XML asked for LeastPrivilege — still InteractiveToken / same user.
|
|
1012
|
+
* Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
|
|
1013
|
+
* installs, and windowsTaskRegistrationHealthy tests encode that contract.
|
|
1014
|
+
*/
|
|
1015
|
+
function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
1016
|
+
if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
|
|
1017
|
+
const count = taskXmlElementCount(principal, "RunLevel");
|
|
1018
|
+
if (count === 0) return true;
|
|
1019
|
+
if (count > 1) return false;
|
|
1020
|
+
const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
|
|
1021
|
+
return value === "leastprivilege" || value === "highestavailable";
|
|
1022
|
+
}
|
|
1023
|
+
|
|
898
1024
|
export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
|
|
899
1025
|
const { bun, cli } = entry;
|
|
900
1026
|
const bunRuntime = durableBunRuntime();
|
|
@@ -1076,7 +1202,7 @@ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string):
|
|
|
1076
1202
|
// `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
|
|
1077
1203
|
const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
|
|
1078
1204
|
if (value === undefined) return false;
|
|
1079
|
-
return taskXmlDecodeEntities(value).trim() === expected.trim();
|
|
1205
|
+
return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
|
|
1080
1206
|
}
|
|
1081
1207
|
|
|
1082
1208
|
function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
@@ -1112,13 +1238,14 @@ export function windowsTaskRegistrationHealthy(
|
|
|
1112
1238
|
return taskXmlElementCount(triggers, "LogonTrigger") > 0
|
|
1113
1239
|
&& taskXmlOptionalValueEquals(trigger, "Enabled", "true")
|
|
1114
1240
|
&& /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
|
|
1115
|
-
&&
|
|
1241
|
+
&& taskXmlRunLevelAcceptable(principal)
|
|
1116
1242
|
&& taskXmlOptionalValueEquals(settings, "Enabled", "true")
|
|
1117
1243
|
&& /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
|
|
1118
1244
|
&& /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
|
|
1119
1245
|
// Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
|
|
1120
1246
|
// quotes we wrote as `"` back to literal `"` on export, so an escaped
|
|
1121
1247
|
// needle never matched and a healthy task read as permanently stale (#608).
|
|
1248
|
+
// Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
|
|
1122
1249
|
&& taskXmlDecodedValueEquals(action, "Command", wscript)
|
|
1123
1250
|
&& taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
|
|
1124
1251
|
}
|
|
@@ -1191,10 +1318,23 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
|
|
|
1191
1318
|
}
|
|
1192
1319
|
}
|
|
1193
1320
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1321
|
+
/**
|
|
1322
|
+
* Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
|
|
1323
|
+
* Used by fresh install (before schtasks /create) and by repair (no elevation).
|
|
1324
|
+
*/
|
|
1325
|
+
function writeWindowsSchedulerAssets(): void {
|
|
1196
1326
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1197
1327
|
writeServiceApiTokenFile();
|
|
1328
|
+
const script = windowsServiceScriptPath();
|
|
1329
|
+
writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
|
|
1330
|
+
// UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
|
|
1331
|
+
// paths on some WSH/codepage combinations — same contract as the task XML below.
|
|
1332
|
+
writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
|
|
1333
|
+
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function installWindows(): void {
|
|
1337
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1198
1338
|
// Transactional backend switch: installing the scheduler backend removes a native
|
|
1199
1339
|
// service first — two live managers would both respawn the proxy (conflict).
|
|
1200
1340
|
if (statusWinswRaw() !== "nonexistent") {
|
|
@@ -1211,17 +1351,78 @@ function installWindows(): void {
|
|
|
1211
1351
|
// End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
|
|
1212
1352
|
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
|
|
1213
1353
|
try { stopWindows(); } catch { /* not running */ }
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
// UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
|
|
1217
|
-
// paths on some WSH/codepage combinations — same contract as the task XML below.
|
|
1218
|
-
writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
|
|
1219
|
-
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
1220
|
-
schtasks(buildWindowsSchtasksCreateArgs(script));
|
|
1354
|
+
writeWindowsSchedulerAssets();
|
|
1355
|
+
schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
|
|
1221
1356
|
schtasks(["/run", "/tn", TASK]);
|
|
1222
1357
|
writeServiceInstallState("scheduler");
|
|
1223
1358
|
}
|
|
1224
1359
|
|
|
1360
|
+
export interface RepairServiceDeps {
|
|
1361
|
+
diagnose?: () => ServiceDiagnostic;
|
|
1362
|
+
assertEnv?: () => void;
|
|
1363
|
+
assertAuth?: () => void;
|
|
1364
|
+
writeSchedulerAssets?: () => void;
|
|
1365
|
+
stopScheduler?: () => void;
|
|
1366
|
+
startScheduler?: () => void;
|
|
1367
|
+
writeSchedulerState?: () => void;
|
|
1368
|
+
writeNativeState?: () => void;
|
|
1369
|
+
repairNative?: () => void | Promise<void>;
|
|
1370
|
+
repairLaunchd?: () => void;
|
|
1371
|
+
repairSystemd?: () => void;
|
|
1372
|
+
/** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
|
|
1373
|
+
platform?: NodeJS.Platform;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Repair an already-installed background service without Task Scheduler re-registration.
|
|
1378
|
+
*
|
|
1379
|
+
* Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC.
|
|
1380
|
+
* Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
|
|
1381
|
+
* macOS/Linux: re-run the user-level install/reload path.
|
|
1382
|
+
*/
|
|
1383
|
+
export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
|
|
1384
|
+
const diagnose = deps.diagnose ?? diagnoseService;
|
|
1385
|
+
const platform = deps.platform ?? process.platform;
|
|
1386
|
+
const diag = diagnose();
|
|
1387
|
+
if (!diag.supported) {
|
|
1388
|
+
throw new Error(`Background service is unsupported (${diag.summary}).`);
|
|
1389
|
+
}
|
|
1390
|
+
if (diag.conflict) {
|
|
1391
|
+
throw new Error(
|
|
1392
|
+
"Cannot repair while Task Scheduler and native WinSW are both present. "
|
|
1393
|
+
+ "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.",
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
if (!diag.installed) {
|
|
1397
|
+
throw new Error("Background service is not installed. Run 'ocx service install' first.");
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
(deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
|
|
1401
|
+
(deps.assertAuth ?? assertServiceAuthEnvironment)();
|
|
1402
|
+
|
|
1403
|
+
if (platform === "win32") {
|
|
1404
|
+
if (diag.backend === "native") {
|
|
1405
|
+
await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))();
|
|
1406
|
+
(deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
|
|
1410
|
+
(deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
|
|
1411
|
+
(deps.startScheduler ?? startWindows)();
|
|
1412
|
+
(deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (platform === "darwin") {
|
|
1416
|
+
(deps.repairLaunchd ?? installLaunchd)();
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (platform === "linux") {
|
|
1420
|
+
(deps.repairSystemd ?? installSystemd)();
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
throw new Error(`Background service repair is unsupported on ${platform}.`);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1225
1426
|
/**
|
|
1226
1427
|
* Opt-in native backend (`ocx service install --native`). Transactional: removes the
|
|
1227
1428
|
* scheduler backend first; on failure the machine is left with NO service (explicitly
|
|
@@ -1711,6 +1912,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1711
1912
|
console.error("--native (WinSW) is Windows-only.");
|
|
1712
1913
|
process.exit(1);
|
|
1713
1914
|
}
|
|
1915
|
+
if (command === "repair") {
|
|
1916
|
+
assertServiceEnvironmentMatchesInstall();
|
|
1917
|
+
assertServiceAuthEnvironment();
|
|
1918
|
+
await repairService();
|
|
1919
|
+
console.log("✅ opencodex background service repaired (assets refreshed, no Task Scheduler re-registration).");
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1714
1922
|
// Non-install subcommands follow the backend recorded at install time (state v2).
|
|
1715
1923
|
const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
|
|
1716
1924
|
const ops = platformOps(backend);
|
|
@@ -1727,6 +1935,11 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1727
1935
|
? "✅ opencodex native service installed + started (windowless, starts at boot, auto-restarts on crash)."
|
|
1728
1936
|
: "✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
|
|
1729
1937
|
if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
|
|
1938
|
+
// Service users never reach the `ocx start` prompt: the proxy they run is the
|
|
1939
|
+
// supervised child, which always carries OCX_SERVICE=1. This command, though, is
|
|
1940
|
+
// hand-typed in a real terminal, so it is the one interactive moment they get.
|
|
1941
|
+
// Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
|
|
1942
|
+
await maybeShowStarPrompt();
|
|
1730
1943
|
break;
|
|
1731
1944
|
case "start":
|
|
1732
1945
|
ops.start();
|
|
@@ -1750,8 +1963,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1750
1963
|
}
|
|
1751
1964
|
break;
|
|
1752
1965
|
case "status": {
|
|
1753
|
-
|
|
1754
|
-
|
|
1966
|
+
if (process.platform === "win32" && backend === "scheduler") {
|
|
1967
|
+
console.log(await inspectWindowsSchedulerServiceStatus());
|
|
1968
|
+
} else {
|
|
1969
|
+
const s = ops.status();
|
|
1970
|
+
console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
|
|
1971
|
+
}
|
|
1755
1972
|
console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
|
|
1756
1973
|
break;
|
|
1757
1974
|
}
|
|
@@ -1783,8 +2000,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
1783
2000
|
console.log("✅ service uninstalled.");
|
|
1784
2001
|
break;
|
|
1785
2002
|
default:
|
|
1786
|
-
console.error("Usage: ocx service [install|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
2003
|
+
console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
1787
2004
|
console.error(" With no subcommand, installs/updates and starts the background service.");
|
|
2005
|
+
console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
|
|
1788
2006
|
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
|
|
1789
2007
|
process.exit(1);
|
|
1790
2008
|
}
|
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
type PolicyRunResult,
|
|
19
19
|
type PolicySkipReason,
|
|
20
20
|
} from "./policy";
|
|
21
|
+
import {
|
|
22
|
+
drainStorageWorkers,
|
|
23
|
+
registerStorageWorker,
|
|
24
|
+
terminateStorageWorker,
|
|
25
|
+
} from "./worker-lifecycle";
|
|
21
26
|
|
|
22
27
|
export type PolicyJobStatus = "idle" | "running";
|
|
23
28
|
|
|
@@ -127,7 +132,7 @@ function disownActiveRun(): void {
|
|
|
127
132
|
export function resetStorageCleanupPolicyJobForTests(): void {
|
|
128
133
|
disownActiveRun();
|
|
129
134
|
if (activeWorker) {
|
|
130
|
-
|
|
135
|
+
void terminateStorageWorker(activeWorker);
|
|
131
136
|
activeWorker = null;
|
|
132
137
|
}
|
|
133
138
|
inflight = null;
|
|
@@ -136,11 +141,24 @@ export function resetStorageCleanupPolicyJobForTests(): void {
|
|
|
136
141
|
state = { status: "idle" };
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Await-able sibling of the reset above, for test teardown.
|
|
146
|
+
*
|
|
147
|
+
* `bun test --isolate` reclaims a file's realm at the file boundary. A storage
|
|
148
|
+
* worker still exiting at that moment trips a Bun-internal assertion on Windows
|
|
149
|
+
* and takes the whole run down, so a suite that spawns workers must be able to
|
|
150
|
+
* wait for them rather than fire-and-forget.
|
|
151
|
+
*/
|
|
152
|
+
export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
|
|
153
|
+
resetStorageCleanupPolicyJobForTests();
|
|
154
|
+
await drainStorageWorkers();
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
/** Terminate an in-flight worker during process shutdown. */
|
|
140
158
|
export function abortStorageCleanupPolicyJob(): void {
|
|
141
159
|
disownActiveRun();
|
|
142
160
|
if (activeWorker) {
|
|
143
|
-
|
|
161
|
+
void terminateStorageWorker(activeWorker);
|
|
144
162
|
activeWorker = null;
|
|
145
163
|
}
|
|
146
164
|
releaseHeldMutationSlot();
|
|
@@ -218,13 +236,14 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
|
|
|
218
236
|
const requestId = crypto.randomUUID();
|
|
219
237
|
let settled = false;
|
|
220
238
|
const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
|
|
239
|
+
registerStorageWorker(worker);
|
|
221
240
|
activeWorker = worker;
|
|
222
241
|
|
|
223
242
|
const timer = setTimeout(() => {
|
|
224
243
|
if (settled) return;
|
|
225
244
|
settled = true;
|
|
226
245
|
cancelActiveRun = null;
|
|
227
|
-
|
|
246
|
+
void terminateStorageWorker(worker);
|
|
228
247
|
if (activeWorker === worker) activeWorker = null;
|
|
229
248
|
reject(new Error("storage_cleanup_worker_timeout"));
|
|
230
249
|
}, WORKER_TIMEOUT_MS);
|
|
@@ -235,8 +254,10 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
|
|
|
235
254
|
cancelActiveRun = null;
|
|
236
255
|
clearTimeout(timer);
|
|
237
256
|
if (activeWorker === worker) activeWorker = null;
|
|
238
|
-
|
|
239
|
-
|
|
257
|
+
// Settle the caller only after the thread is actually gone, so a suite
|
|
258
|
+
// that awaits its request cannot reach the next test file with a worker
|
|
259
|
+
// still exiting behind it.
|
|
260
|
+
void terminateStorageWorker(worker).then(fn, fn);
|
|
240
261
|
};
|
|
241
262
|
|
|
242
263
|
cancelActiveRun = () => {
|
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
withStorageMutationSlot,
|
|
17
17
|
type StorageMutationCoordinatorTestHooks,
|
|
18
18
|
} from "./storage-mutation-coordinator";
|
|
19
|
+
import {
|
|
20
|
+
drainStorageWorkers,
|
|
21
|
+
registerStorageWorker,
|
|
22
|
+
terminateStorageWorker,
|
|
23
|
+
} from "./worker-lifecycle";
|
|
19
24
|
|
|
20
25
|
export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
|
|
21
26
|
/**
|
|
@@ -69,7 +74,7 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
|
|
|
69
74
|
|
|
70
75
|
export function resetRestoreTrashJobForTests(): void {
|
|
71
76
|
if (activeWorker) {
|
|
72
|
-
|
|
77
|
+
void terminateStorageWorker(activeWorker);
|
|
73
78
|
activeWorker = null;
|
|
74
79
|
}
|
|
75
80
|
cancelActiveRun?.();
|
|
@@ -78,10 +83,16 @@ export function resetRestoreTrashJobForTests(): void {
|
|
|
78
83
|
resetStorageMutationCoordinatorForTests();
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/** Await-able reset for test teardown; see policy-job's equivalent for why. */
|
|
87
|
+
export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
|
|
88
|
+
resetRestoreTrashJobForTests();
|
|
89
|
+
await drainStorageWorkers();
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
/** Terminate an in-flight worker during process shutdown. */
|
|
82
93
|
export function abortRestoreTrashJob(): void {
|
|
83
94
|
if (activeWorker) {
|
|
84
|
-
|
|
95
|
+
void terminateStorageWorker(activeWorker);
|
|
85
96
|
activeWorker = null;
|
|
86
97
|
}
|
|
87
98
|
cancelActiveRun?.();
|
|
@@ -117,13 +128,14 @@ function runInWorker(opts: {
|
|
|
117
128
|
const requestId = crypto.randomUUID();
|
|
118
129
|
let settled = false;
|
|
119
130
|
const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
|
|
131
|
+
registerStorageWorker(worker);
|
|
120
132
|
activeWorker = worker;
|
|
121
133
|
|
|
122
134
|
const timer = setTimeout(() => {
|
|
123
135
|
if (settled) return;
|
|
124
136
|
settled = true;
|
|
125
137
|
cancelActiveRun = null;
|
|
126
|
-
|
|
138
|
+
void terminateStorageWorker(worker);
|
|
127
139
|
if (activeWorker === worker) activeWorker = null;
|
|
128
140
|
reject(new Error("restore_worker_timeout"));
|
|
129
141
|
}, WORKER_TIMEOUT_MS);
|
|
@@ -134,8 +146,7 @@ function runInWorker(opts: {
|
|
|
134
146
|
cancelActiveRun = null;
|
|
135
147
|
clearTimeout(timer);
|
|
136
148
|
if (activeWorker === worker) activeWorker = null;
|
|
137
|
-
|
|
138
|
-
fn();
|
|
149
|
+
void terminateStorageWorker(worker).then(fn, fn);
|
|
139
150
|
};
|
|
140
151
|
|
|
141
152
|
cancelActiveRun = () => {
|