@bitkyc08/opencodex 2.10.2 → 2.11.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.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
|
@@ -21,7 +21,12 @@
|
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
22
22
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
|
-
import { join } from "node:path";
|
|
24
|
+
import { join, win32 as win32Path } from "node:path";
|
|
25
|
+
import {
|
|
26
|
+
resolveTrustedWindowsSchtasksExe,
|
|
27
|
+
resolveTrustedWindowsSystemDirectory,
|
|
28
|
+
} from "./lib/windows-elevation";
|
|
29
|
+
import { WINSW_SERVICE_ID } from "./lib/winsw";
|
|
25
30
|
|
|
26
31
|
/** Short: this runs inside admission, and a slow answer is the same as none. */
|
|
27
32
|
export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
|
|
@@ -60,6 +65,18 @@ export interface ProbeRunner {
|
|
|
60
65
|
};
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the
|
|
70
|
+
* UTF-16LE task XML is not corrupted by a UTF-8 decode.
|
|
71
|
+
*/
|
|
72
|
+
export type RawProbeRunner = (file: string, args: readonly string[]) => {
|
|
73
|
+
status: number | null;
|
|
74
|
+
stdout: Buffer;
|
|
75
|
+
stderr: Buffer;
|
|
76
|
+
timedOut: boolean;
|
|
77
|
+
spawnFailed: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
63
80
|
export const defaultProbeRunner: ProbeRunner = (file, args) => {
|
|
64
81
|
const result = spawnSync(file, [...args], {
|
|
65
82
|
encoding: "utf8",
|
|
@@ -76,11 +93,32 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => {
|
|
|
76
93
|
};
|
|
77
94
|
};
|
|
78
95
|
|
|
96
|
+
export const defaultRawProbeRunner: RawProbeRunner = (file, args) => {
|
|
97
|
+
const result = spawnSync(file, [...args], {
|
|
98
|
+
encoding: "buffer",
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
timeout: SERVICE_PROBE_TIMEOUT_MS,
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
status: result.status,
|
|
104
|
+
stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0),
|
|
105
|
+
stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0),
|
|
106
|
+
timedOut: result.signal !== null && result.error === undefined,
|
|
107
|
+
spawnFailed: result.error !== undefined,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
79
111
|
export interface ProbeDeps {
|
|
80
112
|
readonly run?: ProbeRunner;
|
|
113
|
+
/** Raw-buffer runner for bounded Windows service-manager queries. */
|
|
114
|
+
readonly runRaw?: RawProbeRunner;
|
|
81
115
|
readonly platform?: NodeJS.Platform;
|
|
82
116
|
readonly uid?: number;
|
|
83
117
|
readonly home?: string;
|
|
118
|
+
/** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `<home>/.opencodex`. */
|
|
119
|
+
readonly configDir?: string;
|
|
120
|
+
/** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */
|
|
121
|
+
readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown";
|
|
84
122
|
}
|
|
85
123
|
|
|
86
124
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -274,24 +312,496 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
/**
|
|
277
|
-
* Windows
|
|
315
|
+
* Windows: walk the scheduled-task definition chain and report the homes it names.
|
|
316
|
+
*
|
|
317
|
+
* The chain is not one file: the task XML names only the launcher, the launcher
|
|
318
|
+
* (VBS) names only the batch wrapper, and the homes live in the wrapper's
|
|
319
|
+
* `set "CODEX_HOME=..."` / `set "OPENCODEX_HOME=..."` lines. Parsing the XML
|
|
320
|
+
* and stopping would find no homes and read that as agreement, so the walk goes
|
|
321
|
+
* all the way to the wrapper.
|
|
322
|
+
*
|
|
323
|
+
* A `set` line is OMITTED by `buildWindowsServiceScript` when the value was
|
|
324
|
+
* unset at install time (windowsBatchSet returns null for empty values), so a
|
|
325
|
+
* missing home stays `null` — the same contract the launchd/systemd probes use —
|
|
326
|
+
* and a definition that names no homes cannot be mistaken for agreement.
|
|
278
327
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* that as agreement. Reporting `unknown` refuses unattended convergence on
|
|
283
|
-
* Windows, which is the safe direction while the chain walk is unwritten.
|
|
328
|
+
* Registration is answered by bounded `schtasks` queries so a definition staged
|
|
329
|
+
* on disk but never registered is still visible (the interrupted-install case).
|
|
330
|
+
* Every failure to ask is `unknown`, never absence.
|
|
284
331
|
*/
|
|
285
|
-
function
|
|
286
|
-
return
|
|
332
|
+
function windowsTaskName(): string {
|
|
333
|
+
return "opencodex-proxy";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function windowsConfigDirPath(deps: { home: string; configDir?: string }): string {
|
|
337
|
+
if (deps.configDir) return deps.configDir;
|
|
338
|
+
return join(deps.home, ".opencodex");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */
|
|
342
|
+
function decodeWindowsText(buffer: Buffer): string {
|
|
343
|
+
if (buffer.length === 0) return "";
|
|
344
|
+
const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
|
|
345
|
+
const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
|
|
346
|
+
const looksUtf16Le = buffer.length >= 4
|
|
347
|
+
&& buffer[1] === 0x00
|
|
348
|
+
&& buffer[3] === 0x00
|
|
349
|
+
&& buffer[0] !== 0x00;
|
|
350
|
+
if (bomUtf16Le || looksUtf16Le) {
|
|
351
|
+
return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
|
|
352
|
+
}
|
|
353
|
+
if (bomUtf16Be) {
|
|
354
|
+
const swapped = Buffer.alloc(buffer.length - 2);
|
|
355
|
+
for (let i = 2; i + 1 < buffer.length; i += 2) {
|
|
356
|
+
swapped[i - 2] = buffer[i + 1]!;
|
|
357
|
+
swapped[i - 1] = buffer[i]!;
|
|
358
|
+
}
|
|
359
|
+
return swapped.toString("utf16le").trim();
|
|
360
|
+
}
|
|
361
|
+
return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Decode the XML entities emitted by the service-definition writers. */
|
|
365
|
+
function decodeXmlEntities(value: string): string {
|
|
366
|
+
return value
|
|
367
|
+
.replace(/"/g, '"')
|
|
368
|
+
.replace(/</g, "<")
|
|
369
|
+
.replace(/>/g, ">")
|
|
370
|
+
.replace(/'/g, "'")
|
|
371
|
+
.replace(/&/g, "&");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Pull the launcher path out of the task XML `<Arguments>` element. */
|
|
375
|
+
function windowsTaskArguments(xml: string): string | null {
|
|
376
|
+
const match = /<Arguments[^>]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml);
|
|
377
|
+
return match ? decodeXmlEntities(match[1]!.trim()) : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Pull the wrapper path out of a VBS `shell.Run` line.
|
|
382
|
+
*
|
|
383
|
+
* `buildWindowsLauncherVbs` escapes a `"` inside a VBS string literal by
|
|
384
|
+
* doubling it, so a wrapper `C:\...\opencodex-service.cmd` is emitted as
|
|
385
|
+
* `shell.Run """C:\...\opencodex-service.cmd""", 0, True`.
|
|
386
|
+
*/
|
|
387
|
+
function vbsWrappedCommand(body: string): string | null {
|
|
388
|
+
const match = /\.Run\s+"""([^"]*)"""/.exec(body);
|
|
389
|
+
if (match) {
|
|
390
|
+
const unwrapped = match[1]!.trim();
|
|
391
|
+
if (unwrapped.length > 0) return unwrapped;
|
|
392
|
+
}
|
|
393
|
+
const plain = /\.Run\s+"([^"]+)"/.exec(body);
|
|
394
|
+
return plain ? plain[1]!.trim() : null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Pull one `set "NAME=value"` out of a batch wrapper. */
|
|
398
|
+
function batchSetValue(body: string, name: string): string | null {
|
|
399
|
+
const match = new RegExp(`^\\s*set\\s+"${name}=([^"]*)"\\s*$`, "im").exec(body);
|
|
400
|
+
return match ? match[1]!.trim() : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Resolve generated batch env indirection before comparing homes. */
|
|
404
|
+
function decodeBatchPathValue(
|
|
405
|
+
value: string,
|
|
406
|
+
env: Record<string, string | undefined> = process.env,
|
|
407
|
+
): string {
|
|
408
|
+
const escapedPercent = "\u0000";
|
|
409
|
+
const tokens: Record<string, string | undefined> = {
|
|
410
|
+
USERPROFILE: env.USERPROFILE,
|
|
411
|
+
APPDATA: env.APPDATA,
|
|
412
|
+
LOCALAPPDATA: env.LOCALAPPDATA,
|
|
413
|
+
SYSTEMROOT: env.SystemRoot,
|
|
414
|
+
};
|
|
415
|
+
return value
|
|
416
|
+
.replace(/%%/g, escapedPercent)
|
|
417
|
+
.replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => {
|
|
418
|
+
const resolved = tokens[name.toUpperCase()];
|
|
419
|
+
return resolved === undefined ? whole : resolved;
|
|
420
|
+
})
|
|
421
|
+
.replaceAll(escapedPercent, "%");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Validate the generated wrapper before interpreting omitted optional homes. */
|
|
425
|
+
function wrapperLooksGenerated(body: string): boolean {
|
|
426
|
+
return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function normalizeWindowsPath(value: string): string {
|
|
430
|
+
return win32Path.normalize(value.replace(/\//g, "\\")).replace(/[\\]+$/, "").toLowerCase();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** True only when a definition-provided path remains inside the effective OPENCODEX_HOME. */
|
|
434
|
+
function windowsPathInsideConfigDir(candidate: string, configDir: string): boolean {
|
|
435
|
+
const root = normalizeWindowsPath(configDir);
|
|
436
|
+
const path = normalizeWindowsPath(candidate);
|
|
437
|
+
const relative = win32Path.relative(root, path);
|
|
438
|
+
return relative === "" || (relative !== ".." && !relative.startsWith("..\\") && !win32Path.isAbsolute(relative));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Parse the first CSV field emitted by schtasks `/fo CSV`. */
|
|
442
|
+
function csvFirstField(line: string): string {
|
|
443
|
+
const trimmed = line.trim();
|
|
444
|
+
if (!trimmed.startsWith('"')) return (trimmed.split(",", 1)[0] ?? "").trim();
|
|
445
|
+
let value = "";
|
|
446
|
+
for (let i = 1; i < trimmed.length; i += 1) {
|
|
447
|
+
const ch = trimmed[i]!;
|
|
448
|
+
if (ch !== '"') {
|
|
449
|
+
value += ch;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (trimmed[i + 1] === '"') {
|
|
453
|
+
value += '"';
|
|
454
|
+
i += 1;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
return value;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function windowsTaskListContains(body: string, taskName: string): boolean {
|
|
463
|
+
const target = taskName.toLowerCase();
|
|
464
|
+
return body.split(/\r?\n/).some(line => {
|
|
465
|
+
const field = csvFirstField(line).replace(/\//g, "\\").replace(/^\\+/, "");
|
|
466
|
+
return field.toLowerCase() === target;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** English hosts provide a decisive fast path; other locales fall back to a full listing. */
|
|
471
|
+
const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Registration state of the scheduled task.
|
|
475
|
+
*
|
|
476
|
+
* The `/xml` query gives the authoritative registered definition. A nonzero
|
|
477
|
+
* result is locale-dependent. English's task-not-found message is decisive; all
|
|
478
|
+
* other nonzero responses use a bounded full listing as the locale-neutral
|
|
479
|
+
* fallback, and only a successful list without our task proves absence.
|
|
480
|
+
*/
|
|
481
|
+
function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>): {
|
|
482
|
+
registered: "present" | "absent" | "unknown";
|
|
483
|
+
registeredXml: string;
|
|
484
|
+
} {
|
|
485
|
+
let schtasks: string;
|
|
486
|
+
try {
|
|
487
|
+
schtasks = resolveTrustedWindowsSchtasksExe();
|
|
488
|
+
} catch {
|
|
489
|
+
return { registered: "unknown", registeredXml: "" };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]);
|
|
493
|
+
if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" };
|
|
494
|
+
if (queried.status === 0) {
|
|
495
|
+
const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr);
|
|
496
|
+
return registeredXml
|
|
497
|
+
? { registered: "present", registeredXml }
|
|
498
|
+
: { registered: "unknown", registeredXml: "" };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`;
|
|
502
|
+
if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) {
|
|
503
|
+
return { registered: "absent", registeredXml: "" };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]);
|
|
507
|
+
if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
|
|
508
|
+
return { registered: "unknown", registeredXml: "" };
|
|
509
|
+
}
|
|
510
|
+
const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr);
|
|
511
|
+
return windowsTaskListContains(listing, windowsTaskName())
|
|
512
|
+
? { registered: "unknown", registeredXml: "" }
|
|
513
|
+
: { registered: "absent", registeredXml: "" };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
type WinswRegistration = "present" | "absent" | "unknown";
|
|
517
|
+
|
|
518
|
+
/** Query WinSW registration through trusted System32 sc.exe; never execute the user-writable WinSW binary. */
|
|
519
|
+
function probeWinswRegistration(
|
|
520
|
+
deps: Required<Pick<ProbeDeps, "runRaw">> & Pick<ProbeDeps, "winswStatus">,
|
|
521
|
+
): WinswRegistration {
|
|
522
|
+
if (deps.winswStatus) {
|
|
523
|
+
const injected = deps.winswStatus();
|
|
524
|
+
if (injected === "started" || injected === "stopped") return "present";
|
|
525
|
+
if (injected === "nonexistent") return "absent";
|
|
526
|
+
return "unknown";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let sc: string;
|
|
530
|
+
try {
|
|
531
|
+
sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe");
|
|
532
|
+
if (artifactPresence(sc) !== "present") return "unknown";
|
|
533
|
+
} catch {
|
|
534
|
+
return "unknown";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const queried = deps.runRaw(sc, ["query", WINSW_SERVICE_ID]);
|
|
538
|
+
if (queried.spawnFailed || queried.timedOut) return "unknown";
|
|
539
|
+
if (queried.status === 0) return "present";
|
|
540
|
+
|
|
541
|
+
// ERROR_SERVICE_DOES_NOT_EXIST (1060) is locale-invariant. Search raw byte
|
|
542
|
+
// text so localized OEM output cannot affect the numeric classification.
|
|
543
|
+
const text = `${queried.stdout.toString("latin1")}\n${queried.stderr.toString("latin1")}`;
|
|
544
|
+
return /\b1060\b/.test(text) ? "absent" : "unknown";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function inspectWindows(
|
|
548
|
+
deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
|
|
549
|
+
): ServiceManagerInstallation {
|
|
550
|
+
const configDir = windowsConfigDirPath(deps);
|
|
551
|
+
const taskXmlPath = join(configDir, "opencodex-service-task.xml");
|
|
552
|
+
const task = artifactPresence(taskXmlPath);
|
|
553
|
+
const winsw = walkWinswChain(deps);
|
|
554
|
+
const winswInstalled = winsw.kind === "present" && winsw.claims[0].registration === "present";
|
|
555
|
+
const winswStaged = winsw.kind === "present" && winsw.claims[0].registration === "absent";
|
|
556
|
+
|
|
557
|
+
let xml = "";
|
|
558
|
+
if (task !== "absent") {
|
|
559
|
+
try {
|
|
560
|
+
xml = decodeWindowsText(readFileSync(taskXmlPath));
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const registration = probeWindowsTaskRegistration(deps);
|
|
567
|
+
if (registration.registered === "unknown") {
|
|
568
|
+
return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered");
|
|
569
|
+
}
|
|
570
|
+
const schedulerRegistered = registration.registered === "present";
|
|
571
|
+
|
|
572
|
+
// Two live registrations are a conflict even if the staging copy of the task
|
|
573
|
+
// XML disappeared. The authoritative `/query /xml` definition is sufficient
|
|
574
|
+
// to walk the scheduler chain without inventing homes.
|
|
575
|
+
if (winswInstalled && schedulerRegistered) {
|
|
576
|
+
if (!registration.registeredXml.trim()) {
|
|
577
|
+
return unknown("Task Scheduler is registered but its definition XML could not be read");
|
|
578
|
+
}
|
|
579
|
+
const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath);
|
|
580
|
+
if (registeredWalk.kind !== "present") return registeredWalk;
|
|
581
|
+
return {
|
|
582
|
+
kind: "conflict",
|
|
583
|
+
claims: [
|
|
584
|
+
winsw.claims[0],
|
|
585
|
+
{ ...registeredWalk.claims[0], registration: "present" },
|
|
586
|
+
],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (winswInstalled) {
|
|
591
|
+
// A staged scheduler definition beside a registered WinSW service is an
|
|
592
|
+
// interrupted backend switch, not proof that WinSW alone owns the machine.
|
|
593
|
+
if (task !== "absent") {
|
|
594
|
+
return unknown("a scheduled-task definition is staged while the native WinSW service is registered");
|
|
595
|
+
}
|
|
596
|
+
return winsw;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (winsw.kind === "unknown") return winsw;
|
|
600
|
+
|
|
601
|
+
// Staged-but-unregistered WinSW remains evidence. If Scheduler is also live
|
|
602
|
+
// or staged, neither half-finished backend switch can be chosen unattended.
|
|
603
|
+
if (winswStaged && (schedulerRegistered || task !== "absent")) {
|
|
604
|
+
return unknown("native WinSW and Task Scheduler definitions overlap during an incomplete backend switch");
|
|
605
|
+
}
|
|
606
|
+
if (winswStaged && task === "absent" && registration.registered === "absent") {
|
|
607
|
+
return winsw;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (task === "absent") {
|
|
611
|
+
return schedulerRegistered
|
|
612
|
+
? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing")
|
|
613
|
+
: { kind: "absent" };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const staged = walkWindowsChain(deps, xml, taskXmlPath);
|
|
617
|
+
if (staged.kind !== "present") return staged;
|
|
618
|
+
const stagedClaim = staged.claims[0];
|
|
619
|
+
|
|
620
|
+
if (schedulerRegistered) {
|
|
621
|
+
if (!registration.registeredXml.trim()) {
|
|
622
|
+
return unknown("Task Scheduler is registered but its definition XML could not be read");
|
|
623
|
+
}
|
|
624
|
+
const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath);
|
|
625
|
+
if (registeredWalk.kind !== "present") return registeredWalk;
|
|
626
|
+
const registeredClaim = registeredWalk.claims[0];
|
|
627
|
+
if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) {
|
|
628
|
+
return unknown("the registered scheduled task names different homes than the staged task definition");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
kind: "present",
|
|
634
|
+
claims: [{
|
|
635
|
+
...stagedClaim,
|
|
636
|
+
registration: schedulerRegistered ? "present" : "absent",
|
|
637
|
+
}],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Compare two home pairs with Windows path normalization (case, slashes, trailing separators). */
|
|
642
|
+
function homesEqual(
|
|
643
|
+
a: { codexHome: string | null; opencodexHome: string | null },
|
|
644
|
+
b: { codexHome: string | null; opencodexHome: string | null },
|
|
645
|
+
): boolean {
|
|
646
|
+
const norm = (v: string | null): string | null => {
|
|
647
|
+
if (v === null) return null;
|
|
648
|
+
return v.replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase();
|
|
649
|
+
};
|
|
650
|
+
return norm(a.codexHome) === norm(b.codexHome) && norm(a.opencodexHome) === norm(b.opencodexHome);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Walk one scheduled-task definition (staged or registered XML) down to the
|
|
655
|
+
* generated batch wrapper and extract the homes it names. Definition-provided
|
|
656
|
+
* paths are followed only inside the effective OPENCODEX_HOME, preventing a
|
|
657
|
+
* foreign task from turning this ownership probe into an arbitrary local/UNC
|
|
658
|
+
* file read while preserving interrupted-reinstall diagnostics within the
|
|
659
|
+
* generated service-asset directory.
|
|
660
|
+
*/
|
|
661
|
+
function walkWindowsChain(
|
|
662
|
+
deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir">,
|
|
663
|
+
xml: string,
|
|
664
|
+
definitionPath: string,
|
|
665
|
+
): ServiceManagerInstallation {
|
|
666
|
+
const configDir = windowsConfigDirPath(deps);
|
|
667
|
+
|
|
668
|
+
const launcherArg = windowsTaskArguments(xml);
|
|
669
|
+
if (!launcherArg) {
|
|
670
|
+
return unknown("the scheduled-task XML names no launcher to run");
|
|
671
|
+
}
|
|
672
|
+
const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1];
|
|
673
|
+
if (!launcherPath) {
|
|
674
|
+
return unknown("the scheduled-task XML launcher argument is not a quoted path");
|
|
675
|
+
}
|
|
676
|
+
if (!windowsPathInsideConfigDir(launcherPath, configDir)) {
|
|
677
|
+
return unknown(`the scheduled-task XML names ${launcherPath}, outside the expected launcher directory ${configDir}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const launcher = artifactPresence(launcherPath);
|
|
681
|
+
if (launcher === "absent") {
|
|
682
|
+
return unknown(`the scheduled-task launcher is missing: ${launcherPath}`);
|
|
683
|
+
}
|
|
684
|
+
let launcherBody: string;
|
|
685
|
+
try {
|
|
686
|
+
launcherBody = decodeWindowsText(readFileSync(launcherPath));
|
|
687
|
+
} catch (error) {
|
|
688
|
+
return unknown(`the scheduled-task launcher could not be read: ${String(error)}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const wrapperPath = vbsWrappedCommand(launcherBody);
|
|
692
|
+
if (!wrapperPath) {
|
|
693
|
+
return unknown(`the launcher ${launcherPath} names no wrapper to run`);
|
|
694
|
+
}
|
|
695
|
+
if (!windowsPathInsideConfigDir(wrapperPath, configDir)) {
|
|
696
|
+
return unknown(`the scheduled-task launcher names ${wrapperPath}, outside the expected wrapper directory ${configDir}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const wrapper = artifactPresence(wrapperPath);
|
|
700
|
+
if (wrapper === "absent") {
|
|
701
|
+
return unknown(`the launcher wrapper is missing: ${wrapperPath}`);
|
|
702
|
+
}
|
|
703
|
+
let wrapperBody: string;
|
|
704
|
+
try {
|
|
705
|
+
wrapperBody = decodeWindowsText(readFileSync(wrapperPath));
|
|
706
|
+
} catch (error) {
|
|
707
|
+
return unknown(`the launcher wrapper could not be read: ${String(error)}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (!wrapperLooksGenerated(wrapperBody)) {
|
|
711
|
+
return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME");
|
|
715
|
+
const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME");
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
kind: "present",
|
|
719
|
+
claims: [{
|
|
720
|
+
backend: "scheduler",
|
|
721
|
+
definitionPath,
|
|
722
|
+
homes: {
|
|
723
|
+
codexHome: rawCodexHome === null ? null : decodeBatchPathValue(rawCodexHome),
|
|
724
|
+
opencodexHome: rawOpencodexHome === null ? null : decodeBatchPathValue(rawOpencodexHome),
|
|
725
|
+
},
|
|
726
|
+
registration: "absent",
|
|
727
|
+
}],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Walk the WinSW native-backend definition. SCM registration is queried via a
|
|
733
|
+
* trusted, bounded `sc.exe query`; the WinSW executable itself is never run by
|
|
734
|
+
* this read-only ownership probe.
|
|
735
|
+
*/
|
|
736
|
+
function walkWinswChain(
|
|
737
|
+
deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
|
|
738
|
+
): ServiceManagerInstallation {
|
|
739
|
+
const configDir = windowsConfigDirPath(deps);
|
|
740
|
+
const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`);
|
|
741
|
+
const xmlPath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.xml`);
|
|
742
|
+
const xml = artifactPresence(xmlPath);
|
|
743
|
+
const exe = artifactPresence(exePath);
|
|
744
|
+
const registration = probeWinswRegistration(deps);
|
|
745
|
+
|
|
746
|
+
if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" };
|
|
747
|
+
if (registration === "unknown") {
|
|
748
|
+
return unknown("the native WinSW service registration could not be verified");
|
|
749
|
+
}
|
|
750
|
+
if (xml === "absent" || exe === "absent") {
|
|
751
|
+
return unknown("the native WinSW service registration could not be verified");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let body: string;
|
|
755
|
+
try {
|
|
756
|
+
body = decodeWindowsText(readFileSync(xmlPath));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
return unknown(`the WinSW XML could not be read: ${String(error)}`);
|
|
759
|
+
}
|
|
760
|
+
if (!winswXmlLooksGenerated(body)) {
|
|
761
|
+
return unknown(`the WinSW XML does not look like a generated opencodex service definition: ${xmlPath}`);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const envValue = (name: string): string | null => {
|
|
765
|
+
const tag = new RegExp(`<env\\b[^>]*\\bname=["']${name}["'][^>]*>`, "i").exec(body);
|
|
766
|
+
if (!tag) return null;
|
|
767
|
+
const value = /value=(["'])(.*?)\1/i.exec(tag[0]);
|
|
768
|
+
return value ? decodeXmlEntities(value[2]!) : null;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
kind: "present",
|
|
773
|
+
claims: [{
|
|
774
|
+
backend: "winsw",
|
|
775
|
+
definitionPath: exePath,
|
|
776
|
+
homes: {
|
|
777
|
+
codexHome: envValue("CODEX_HOME"),
|
|
778
|
+
opencodexHome: envValue("OPENCODEX_HOME"),
|
|
779
|
+
},
|
|
780
|
+
registration,
|
|
781
|
+
}],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** The generated WinSW XML embeds the SCM id and a `start --port` invocation. */
|
|
786
|
+
function winswXmlLooksGenerated(body: string): boolean {
|
|
787
|
+
return /<id>\s*opencodex-proxy-native\s*<\/id>/i.test(body)
|
|
788
|
+
&& /<arguments>.*?start\s+--port\b/i.test(body);
|
|
287
789
|
}
|
|
288
790
|
|
|
289
791
|
export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation {
|
|
290
792
|
const platform = deps.platform ?? process.platform;
|
|
291
793
|
const run = deps.run ?? defaultProbeRunner;
|
|
794
|
+
const runRaw = deps.runRaw ?? defaultRawProbeRunner;
|
|
292
795
|
const home = deps.home ?? homedir();
|
|
293
796
|
if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home });
|
|
294
797
|
if (platform === "linux") return inspectSystemd({ run, home });
|
|
295
|
-
if (platform === "win32")
|
|
798
|
+
if (platform === "win32") {
|
|
799
|
+
return inspectWindows({
|
|
800
|
+
runRaw,
|
|
801
|
+
home,
|
|
802
|
+
configDir: deps.configDir,
|
|
803
|
+
winswStatus: deps.winswStatus,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
296
806
|
return unknown(`no service manager probe for platform ${platform}`);
|
|
297
807
|
}
|