@bitkyc08/opencodex 2.17.1-preview.20260814 → 2.19.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.
Files changed (50) hide show
  1. package/gui/dist/assets/{index-DUCH59lJ.css → index-CQ7bIKee.css} +1 -1
  2. package/gui/dist/assets/{index-ta3-_hgj.js → index-D_JUZLEC.js} +16 -16
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/cursor/live-transport.ts +17 -5
  7. package/src/adapters/cursor/protobuf-events.ts +662 -20
  8. package/src/adapters/cursor/tool-definitions.ts +12 -6
  9. package/src/adapters/google-antigravity-wire.ts +4 -3
  10. package/src/adapters/google.ts +22 -3
  11. package/src/bridge.ts +12 -2
  12. package/src/chat/inbound.ts +24 -1
  13. package/src/cli/index.ts +11 -0
  14. package/src/codex/app-server-processes.ts +3 -3
  15. package/src/codex/shim.ts +100 -5
  16. package/src/codex/user-identity.ts +36 -6
  17. package/src/config.ts +0 -2
  18. package/src/generated/compatibility-version.json +52 -44
  19. package/src/lib/errors.ts +27 -0
  20. package/src/lib/token-estimate.ts +19 -2
  21. package/src/lib/windows-elevation.ts +37 -0
  22. package/src/lib/windows-secret-acl.ts +7 -0
  23. package/src/lib/windows-text.ts +106 -0
  24. package/src/lib/windows-user-principal.ts +0 -2
  25. package/src/oauth/index.ts +1 -1
  26. package/src/oauth/store.ts +32 -18
  27. package/src/providers/antigravity-models.ts +25 -5
  28. package/src/providers/free-directory.ts +1 -1
  29. package/src/providers/registry.ts +6 -3
  30. package/src/responses/spill-store.ts +20 -1
  31. package/src/responses/state.ts +159 -3
  32. package/src/server/chat-completions.ts +4 -2
  33. package/src/server/effort-policy.ts +18 -0
  34. package/src/server/index.ts +5 -1
  35. package/src/server/management/logs-usage-routes.ts +7 -22
  36. package/src/server/request-log.ts +48 -3
  37. package/src/server/responses/core.ts +59 -15
  38. package/src/server/responses/encrypted-payload.ts +58 -38
  39. package/src/server/responses/fetch-helpers.ts +12 -4
  40. package/src/server/responses/input-admission.ts +169 -0
  41. package/src/server/responses/policy-fallback.ts +13 -2
  42. package/src/server/responses/ws-upstream.ts +115 -6
  43. package/src/service-manager-probe.ts +23 -37
  44. package/src/service.ts +233 -25
  45. package/src/tray/windows.ts +0 -2
  46. package/src/types.ts +10 -2
  47. package/src/update/job.ts +2 -2
  48. package/src/usage/summary.ts +21 -4
  49. package/src/vision/index.ts +21 -4
  50. package/src/web-search/index.ts +2 -1
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Pre-dispatch input admission (#1412).
3
+ *
4
+ * Refuses a turn whose estimated input cannot plausibly fit the model context window,
5
+ * BEFORE auth resolution, circuit admission, or any upstream I/O. #1412 reported ~127k of
6
+ * real context compounding to 1.3M-1.6M tokens and crashing the proxy; the provider would
7
+ * reject such a turn anyway, so paying for the round trip buys nothing.
8
+ *
9
+ * Deliberately narrow. This is not a context manager and not a compaction trigger: it
10
+ * catches the pathological case and stays out of the way otherwise. Every uncertainty
11
+ * resolves toward admitting.
12
+ */
13
+ import { nativeOpenAiContextWindow } from "../../codex/catalog/metadata";
14
+ import { estimateTokens } from "../../lib/token-estimate";
15
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
16
+ import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
17
+
18
+ /**
19
+ * Multiplier applied to the ceiling before refusing.
20
+ *
21
+ * 2.5, not something tighter, because `estimateTokens` can overshoot by 1.6x on its own.
22
+ * `cjkRatio` samples every `stride`-th character, so a payload of fixed-width records whose
23
+ * length aligns with the stride samples as 100% CJK while being ~1.6% CJK, firing the
24
+ * 2.5-chars/token clamp instead of the 4.0 default. Measured on Bun 1.3.14: 126,046 chars,
25
+ * true CJK ratio 0.0161, sampled ratio 1.0, estimate inflated 1.6x. Since 4.0 / 2.5 = 1.6
26
+ * is that branch maximum divergence, a threshold at or under 1.6 would convert the
27
+ * estimator error bar into false 413s.
28
+ *
29
+ * 2.5 sits above it with room for the ~10% model-family ratio spread, and still refuses the
30
+ * #1412 shape (10x) four times over.
31
+ */
32
+ export const ADMISSION_TOLERANCE = 2.5;
33
+
34
+ /**
35
+ * Token cost charged for a remote image URL. The bytes are not in this request — the
36
+ * provider fetches them — so the URL own length is not the cost. A small flat charge
37
+ * acknowledges the tiles the image will occupy without pretending to know its dimensions.
38
+ */
39
+ const REMOTE_IMAGE_TOKENS = 850;
40
+
41
+ /** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
42
+ const IMAGE_BYTES_PER_TOKEN = 750;
43
+
44
+ export interface InputAdmissionResult {
45
+ admitted: boolean;
46
+ estimatedTokens: number;
47
+ /** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
48
+ ceiling: number | null;
49
+ }
50
+
51
+ function positive(value: unknown): number | null {
52
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
53
+ }
54
+
55
+ /**
56
+ * Charge a `data:` URL by its DECODED size rather than its character length: base64 inflates
57
+ * by 4/3, so charging the string would overcount by a third. A remote URL is charged flat.
58
+ */
59
+ function imageTokens(imageUrl: string): number {
60
+ if (!imageUrl.startsWith("data:")) return REMOTE_IMAGE_TOKENS;
61
+ const comma = imageUrl.indexOf(",");
62
+ if (comma < 0) return REMOTE_IMAGE_TOKENS;
63
+ const payload = imageUrl.length - comma - 1;
64
+ if (payload <= 0) return 0;
65
+ const decoded = Math.floor((payload * 3) / 4);
66
+ return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
67
+ }
68
+
69
+ function contentPartTokens(part: OcxContentPart, modelId: string): number {
70
+ return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId);
71
+ }
72
+
73
+ function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
74
+ if (typeof content === "string") return estimateTokens(content, modelId);
75
+ let total = 0;
76
+ for (const part of content) total += contentPartTokens(part, modelId);
77
+ return total;
78
+ }
79
+
80
+ /**
81
+ * Estimate the input tokens of a parsed request.
82
+ *
83
+ * Walks the whole `OcxMessage` union rather than user text alone. Assistant turns carry
84
+ * their content as `OcxAssistantContentPart[]` — text, thinking blocks, and tool calls whose
85
+ * JSON arguments are frequently the largest single item in an agent conversation. A walk
86
+ * that counted only `{type:"text"}` would undercount exactly the turns that trigger this
87
+ * gate.
88
+ */
89
+ export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): number {
90
+ const { context } = parsed;
91
+ let total = 0;
92
+
93
+ for (const prompt of context.systemPrompt ?? []) total += estimateTokens(prompt, modelId);
94
+
95
+ for (const message of context.messages) {
96
+ if (message.role === "assistant") {
97
+ for (const part of message.content) {
98
+ if (part.type === "text") total += estimateTokens(part.text, modelId);
99
+ else if (part.type === "thinking") total += estimateTokens(part.thinking, modelId);
100
+ else total += estimateTokens(part.name, modelId) + estimateTokens(JSON.stringify(part.arguments), modelId);
101
+ }
102
+ // Opaque provider blob replayed verbatim upstream, so it costs real input tokens.
103
+ if (message.kiroRedactedReasoning) total += estimateTokens(message.kiroRedactedReasoning, modelId);
104
+ continue;
105
+ }
106
+ total += contentTokens(message.content, modelId);
107
+ }
108
+
109
+ // Tool schemas ride every turn: name, description, and the JSON parameter schema all
110
+ // reach the upstream, and a large MCP catalog can dominate a short conversation.
111
+ for (const tool of context.tools ?? []) {
112
+ total += estimateTokens(tool.name, modelId)
113
+ + estimateTokens(tool.description, modelId)
114
+ + estimateTokens(JSON.stringify(tool.parameters), modelId);
115
+ }
116
+
117
+ return total;
118
+ }
119
+
120
+ /**
121
+ * Resolve the admission ceiling. Pure: no filesystem, no catalog, no registry scan.
122
+ *
123
+ * `provider` must be the ROUTED config (`route.provider`), which `routedProviderConfig`
124
+ * has already transport-guarded and merged. Re-deriving from `config.providers[name]` would
125
+ * reject a user-defined provider that merely shares a built-in name using limits that
126
+ * belong to a different service.
127
+ */
128
+ export function resolveInputCeiling(
129
+ provider: OcxProviderConfig,
130
+ providerName: string,
131
+ modelId: string,
132
+ ): number | null {
133
+ const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow);
134
+
135
+ // The canonical `openai` registry entry declares no context fields, so without this the
136
+ // gate would be inert on the default Codex route. All three clauses are load-bearing: a
137
+ // transport-mismatched custom provider named "openai" is preserved verbatim by routing
138
+ // and must not inherit built-in native limits, and a routed `provider/model` id is not a
139
+ // native slug. Static maps only — no catalog read.
140
+ const native = configured === null
141
+ && providerName === OPENAI_CODEX_PROVIDER_ID
142
+ && isCanonicalOpenAiForwardProvider(provider)
143
+ && !modelId.includes("/")
144
+ ? positive(nativeOpenAiContextWindow(modelId))
145
+ : null;
146
+
147
+ const window = configured ?? native;
148
+ // modelMaxInputTokens is an input-only cap, so it can only tighten the window.
149
+ const maxInput = positive(provider.modelMaxInputTokens?.[modelId]);
150
+ if (window === null) return maxInput;
151
+ return maxInput === null ? window : Math.min(window, maxInput);
152
+ }
153
+
154
+ /**
155
+ * Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
156
+ *
157
+ * The caller is responsible for skipping compaction turns — see the call site in core.ts.
158
+ */
159
+ export function checkInputAdmission(
160
+ parsed: OcxParsedRequest,
161
+ provider: OcxProviderConfig,
162
+ providerName: string,
163
+ modelId: string,
164
+ ): InputAdmissionResult {
165
+ const ceiling = resolveInputCeiling(provider, providerName, modelId);
166
+ if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null };
167
+ const estimatedTokens = estimateInputTokens(parsed, modelId);
168
+ return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling };
169
+ }
@@ -114,6 +114,17 @@ export async function handleResponsesWithPolicyFallback(
114
114
  deps: PolicyFallbackDeps = {},
115
115
  ): Promise<Response> {
116
116
  const runCore = deps.runCore ?? handleResponsesCore;
117
+ let requestBodyReadNotified = false;
118
+ const coreOptions: CoreOptions = options.onRequestBodyRead
119
+ ? {
120
+ ...options,
121
+ onRequestBodyRead: () => {
122
+ if (requestBodyReadNotified) return;
123
+ requestBodyReadNotified = true;
124
+ options.onRequestBodyRead?.();
125
+ },
126
+ }
127
+ : options;
117
128
  let rawBody: Record<string, unknown> | null = null;
118
129
  try {
119
130
  const parsed = await readJsonRequestBody(req.clone());
@@ -122,7 +133,7 @@ export async function handleResponsesWithPolicyFallback(
122
133
  // Core owns the client-facing parse/decompression error.
123
134
  }
124
135
 
125
- let response = await runCore(req, config, logCtx, options);
136
+ let response = await runCore(req, config, logCtx, coreOptions);
126
137
  const initialTrace = logCtx.routeDecision;
127
138
  const initialRequestedModel = logCtx.requestedModel;
128
139
  if (!rawBody || !isPolicyDecision(initialTrace)) return response;
@@ -140,7 +151,7 @@ export async function handleResponsesWithPolicyFallback(
140
151
  finishFailedPolicyAttempt(logCtx, response.status);
141
152
  const retryRequest = requestWithCandidate(req, rawBody, next);
142
153
  try {
143
- response = await runCore(retryRequest, config, logCtx, options);
154
+ response = await runCore(retryRequest, config, logCtx, coreOptions);
144
155
  } finally {
145
156
  logCtx.requestedModel = initialRequestedModel;
146
157
  logCtx.routeDecision = initialTrace;
@@ -12,14 +12,79 @@
12
12
  // returned event frames as an SSE byte stream, so every downstream consumer
13
13
  // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
14
14
 
15
+ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
16
+ import { compareBunVersions } from "../../lib/bun-stream-caps";
17
+
15
18
  const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
16
19
  const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
17
20
  const WS_BETA = "responses_websockets=2026-02-06";
18
21
  // If the 101 never arrives (network black hole), give SSE a chance well before
19
22
  // the caller's connect timeout (default 200s) would fire.
20
23
  const UPGRADE_DEADLINE_MS = 10_000;
24
+ // Keep the push-based WS transport inside the same memory envelope as the
25
+ // bounded SSE relays that consume this response. Unlike fetch response bodies,
26
+ // a WebSocket cannot be paused when a ReadableStream applies backpressure, so
27
+ // an upstream that outruns the consumer must be disconnected.
28
+ export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
29
+ export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
30
+ export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
31
+
32
+ export type BunRuntimeIdentity = {
33
+ version: string;
34
+ versionWithSha: string;
35
+ };
36
+
37
+ export type BunRuntimeGateInput = string | BunRuntimeIdentity;
38
+
39
+ const codexWsUpstreamResponses = new WeakSet<Response>();
40
+
41
+ /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
42
+ export function isCodexWsUpstreamResponse(response: Response): boolean {
43
+ return codexWsUpstreamResponses.has(response);
44
+ }
45
+
46
+ export function currentBunRuntimeIdentity(): BunRuntimeIdentity {
47
+ return {
48
+ version: Bun.version,
49
+ versionWithSha: Bun.version_with_sha,
50
+ };
51
+ }
21
52
 
22
- export function shouldUseCodexWsUpstream(url: string, init?: RequestInit): boolean {
53
+ function boundedRelayVersion(input: BunRuntimeGateInput): string | null {
54
+ if (typeof input === "string") return input.trim() || null;
55
+ const numericVersion = input.version.trim();
56
+ const numericMatch = /^(\d+\.\d+\.\d+)$/.exec(numericVersion);
57
+ const detailedMatch = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s+\([0-9a-fA-F]+\)$/.exec(
58
+ input.versionWithSha.trim(),
59
+ );
60
+ if (!numericMatch || !detailedMatch) return null;
61
+ const detailedNumeric = /^(\d+\.\d+\.\d+)/.exec(detailedMatch[1])?.[1];
62
+ return detailedNumeric === numericMatch[1] ? detailedMatch[1] : null;
63
+ }
64
+
65
+ /**
66
+ * Bun 1.3.14 does not propagate a stalled HTTP response socket back to a JS
67
+ * ReadableStream producer on Windows. A real raw-TCP slow-client probe drained
68
+ * the entire upstream despite the eager relay queue; Bun 1.4.0-canary.1 stopped
69
+ * below one MiB. Prereleases still fail closed; release builds before 1.4.0
70
+ * fall back to HTTP SSE.
71
+ */
72
+ export function bunSupportsBoundedCodexWsRelay(
73
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
74
+ ): boolean {
75
+ const version = boundedRelayVersion(runtime);
76
+ if (!version) return false;
77
+ if (/^\d+\.\d+\.\d+-/.test(version.trim())) return false;
78
+ const comparison = compareBunVersions(version, MIN_BOUNDED_CODEX_WS_BUN_VERSION);
79
+ return comparison !== null && comparison >= 0;
80
+ }
81
+
82
+ export function shouldUseCodexWsUpstream(
83
+ url: string,
84
+ init?: RequestInit,
85
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
86
+ ): boolean {
87
+ if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
23
88
  if (url !== CODEX_RESPONSES_HTTP_URL) return false;
24
89
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
25
90
  const body = init?.body;
@@ -41,7 +106,11 @@ export function codexWsUpstreamFetch(
41
106
  url: string,
42
107
  init: RequestInit,
43
108
  sseFallback: typeof globalThis.fetch,
109
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
44
110
  ): Promise<Response> {
111
+ if (!bunSupportsBoundedCodexWsRelay(runtime)) {
112
+ return sseFallback(url, init);
113
+ }
45
114
  const signal = init.signal ?? undefined;
46
115
  if (signal?.aborted) {
47
116
  return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
@@ -91,6 +160,13 @@ export function codexWsUpstreamFetch(
91
160
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
92
161
  const encoder = new TextEncoder();
93
162
 
163
+ const failStream = (message: string) => {
164
+ if (terminal) return;
165
+ terminal = true;
166
+ try { controller?.error(new Error(message)); } catch { /* stream already done */ }
167
+ try { ws.close(); } catch { /* already closing */ }
168
+ };
169
+
94
170
  const upgradeTimer = setTimeout(() => {
95
171
  if (opened || settledPreOpen) return;
96
172
  settledPreOpen = true;
@@ -110,12 +186,14 @@ export function codexWsUpstreamFetch(
110
186
  reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
111
187
  return;
112
188
  }
113
- try { ws.close(); } catch { /* already closing */ }
114
189
  if (controller && !terminal) {
115
190
  terminal = true;
116
191
  // Mirror an aborted fetch: the body read rejects with the abort reason.
117
192
  try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
118
193
  }
194
+ // Error the body before close(): test doubles and some runtimes dispatch
195
+ // close synchronously, and the caller's abort reason must stay authoritative.
196
+ try { ws.close(); } catch { /* already closing */ }
119
197
  };
120
198
  signal?.addEventListener("abort", onAbort, { once: true });
121
199
 
@@ -138,19 +216,33 @@ export function codexWsUpstreamFetch(
138
216
  const stream = new ReadableStream<Uint8Array>({
139
217
  start(c) { controller = c; },
140
218
  cancel() { try { ws.close(); } catch { /* already closing */ } },
141
- });
142
- resolve(new Response(stream, {
219
+ }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
220
+ const response = new Response(stream, {
143
221
  status: 200,
144
222
  // The 101 response headers (x-codex-*-reset-at quota hints) are not
145
223
  // exposed by Bun's WebSocket; the periodic quota poller covers those.
146
224
  headers: { "content-type": "text/event-stream; charset=utf-8" },
147
- }));
225
+ });
226
+ codexWsUpstreamResponses.add(response);
227
+ resolve(response);
148
228
  });
149
229
 
150
230
  ws.addEventListener("message", (event) => {
151
231
  if (!controller || terminal) return;
152
232
  const text = typeof event.data === "string" ? event.data : "";
153
233
  if (!text) return;
234
+ // UTF-8 byte length is always at least the JS string length. Reject this
235
+ // cheap lower bound before parsing so an obviously oversized frame does
236
+ // not create another large object graph.
237
+ if (text.length > MAX_CODEX_WS_FRAME_BYTES) {
238
+ failStream("codex websocket frame exceeds the response size limit");
239
+ return;
240
+ }
241
+ const encodedText = encoder.encode(text);
242
+ if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
243
+ failStream("codex websocket frame exceeds the response size limit");
244
+ return;
245
+ }
154
246
  let type: unknown;
155
247
  try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
156
248
  if (typeof type !== "string") return;
@@ -158,9 +250,26 @@ export function codexWsUpstreamFetch(
158
250
  // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
159
251
  // so downstream clients see exactly the stream shape they always got.
160
252
  if (!type.startsWith("response.") && type !== "error") return;
253
+ const prefix = encoder.encode(`event: ${type}\ndata: `);
254
+ const suffix = encoder.encode("\n\n");
255
+ const frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength;
256
+ if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
257
+ failStream("codex websocket frame exceeds the response size limit");
258
+ return;
259
+ }
260
+ const availableBytes = controller.desiredSize ?? 0;
261
+ if (frameBytes > availableBytes) {
262
+ failStream("codex websocket response exceeded the buffered queue limit");
263
+ return;
264
+ }
265
+ const sseFrame = new Uint8Array(frameBytes);
266
+ sseFrame.set(prefix);
267
+ sseFrame.set(encodedText, prefix.byteLength);
268
+ sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
161
269
  try {
162
- controller.enqueue(encoder.encode(`event: ${type}\ndata: ${text}\n\n`));
270
+ controller.enqueue(sseFrame);
163
271
  } catch {
272
+ failStream("codex websocket response stream closed while enqueueing");
164
273
  return;
165
274
  }
166
275
  if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
@@ -26,6 +26,7 @@ import {
26
26
  resolveTrustedWindowsSchtasksExe,
27
27
  resolveTrustedWindowsSystemDirectory,
28
28
  } from "./lib/windows-elevation";
29
+ import { decodeWindowsTextBytes } from "./lib/windows-text";
29
30
  import { WINSW_SERVICE_ID } from "./lib/winsw";
30
31
 
31
32
  /** Short: this runs inside admission, and a slow answer is the same as none. */
@@ -119,6 +120,8 @@ export interface ProbeDeps {
119
120
  readonly configDir?: string;
120
121
  /** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */
121
122
  readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown";
123
+ /** Test seam for redirected Windows legacy-codepage output. */
124
+ readonly windowsLocale?: string;
122
125
  }
123
126
 
124
127
  const LABEL = "com.opencodex.proxy";
@@ -261,9 +264,8 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
261
264
  "--user", "show", TASK,
262
265
  "-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
263
266
  ]);
264
- if (shown.spawnFailed || shown.timedOut) {
265
- return unknown(`systemctl could not be asked: ${shown.timedOut ? "timed out" : shown.stderr.trim()}`);
266
- }
267
+ if (shown.spawnFailed) return { kind: "absent" };
268
+ if (shown.timedOut) return unknown("systemctl could not be asked: timed out");
267
269
  if (shown.status !== 0) {
268
270
  // A missing unit still exits ZERO and says not-found; a non-zero status means
269
271
  // the question never reached the bus.
@@ -338,29 +340,6 @@ function windowsConfigDirPath(deps: { home: string; configDir?: string }): strin
338
340
  return join(deps.home, ".opencodex");
339
341
  }
340
342
 
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
343
  /** Decode the XML entities emitted by the service-definition writers. */
365
344
  function decodeXmlEntities(value: string): string {
366
345
  return value
@@ -478,7 +457,9 @@ const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
478
457
  * other nonzero responses use a bounded full listing as the locale-neutral
479
458
  * fallback, and only a successful list without our task proves absence.
480
459
  */
481
- function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>): {
460
+ function probeWindowsTaskRegistration(
461
+ deps: Required<Pick<ProbeDeps, "runRaw">> & Pick<ProbeDeps, "windowsLocale">,
462
+ ): {
482
463
  registered: "present" | "absent" | "unknown";
483
464
  registeredXml: string;
484
465
  } {
@@ -492,13 +473,14 @@ function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>)
492
473
  const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]);
493
474
  if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" };
494
475
  if (queried.status === 0) {
495
- const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr);
476
+ const registeredXml = decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale })
477
+ || decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale });
496
478
  return registeredXml
497
479
  ? { registered: "present", registeredXml }
498
480
  : { registered: "unknown", registeredXml: "" };
499
481
  }
500
482
 
501
- const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`;
483
+ const queryText = `${decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale })}\n${decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale })}`;
502
484
  if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) {
503
485
  return { registered: "absent", registeredXml: "" };
504
486
  }
@@ -507,7 +489,8 @@ function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>)
507
489
  if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
508
490
  return { registered: "unknown", registeredXml: "" };
509
491
  }
510
- const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr);
492
+ const listing = decodeWindowsTextBytes(listed.stdout, { locale: deps.windowsLocale })
493
+ || decodeWindowsTextBytes(listed.stderr, { locale: deps.windowsLocale });
511
494
  return windowsTaskListContains(listing, windowsTaskName())
512
495
  ? { registered: "unknown", registeredXml: "" }
513
496
  : { registered: "absent", registeredXml: "" };
@@ -545,7 +528,8 @@ function probeWinswRegistration(
545
528
  }
546
529
 
547
530
  function inspectWindows(
548
- deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
531
+ deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
532
+ & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale">,
549
533
  ): ServiceManagerInstallation {
550
534
  const configDir = windowsConfigDirPath(deps);
551
535
  const taskXmlPath = join(configDir, "opencodex-service-task.xml");
@@ -557,7 +541,7 @@ function inspectWindows(
557
541
  let xml = "";
558
542
  if (task !== "absent") {
559
543
  try {
560
- xml = decodeWindowsText(readFileSync(taskXmlPath));
544
+ xml = decodeWindowsTextBytes(readFileSync(taskXmlPath), { locale: deps.windowsLocale });
561
545
  } catch (error) {
562
546
  return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`);
563
547
  }
@@ -659,7 +643,7 @@ function homesEqual(
659
643
  * generated service-asset directory.
660
644
  */
661
645
  function walkWindowsChain(
662
- deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir">,
646
+ deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir" | "windowsLocale">,
663
647
  xml: string,
664
648
  definitionPath: string,
665
649
  ): ServiceManagerInstallation {
@@ -683,7 +667,7 @@ function walkWindowsChain(
683
667
  }
684
668
  let launcherBody: string;
685
669
  try {
686
- launcherBody = decodeWindowsText(readFileSync(launcherPath));
670
+ launcherBody = decodeWindowsTextBytes(readFileSync(launcherPath), { locale: deps.windowsLocale });
687
671
  } catch (error) {
688
672
  return unknown(`the scheduled-task launcher could not be read: ${String(error)}`);
689
673
  }
@@ -702,7 +686,7 @@ function walkWindowsChain(
702
686
  }
703
687
  let wrapperBody: string;
704
688
  try {
705
- wrapperBody = decodeWindowsText(readFileSync(wrapperPath));
689
+ wrapperBody = decodeWindowsTextBytes(readFileSync(wrapperPath), { locale: deps.windowsLocale });
706
690
  } catch (error) {
707
691
  return unknown(`the launcher wrapper could not be read: ${String(error)}`);
708
692
  }
@@ -734,7 +718,8 @@ function walkWindowsChain(
734
718
  * this read-only ownership probe.
735
719
  */
736
720
  function walkWinswChain(
737
- deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
721
+ deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
722
+ & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale">,
738
723
  ): ServiceManagerInstallation {
739
724
  const configDir = windowsConfigDirPath(deps);
740
725
  const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`);
@@ -753,7 +738,7 @@ function walkWinswChain(
753
738
 
754
739
  let body: string;
755
740
  try {
756
- body = decodeWindowsText(readFileSync(xmlPath));
741
+ body = decodeWindowsTextBytes(readFileSync(xmlPath), { locale: deps.windowsLocale });
757
742
  } catch (error) {
758
743
  return unknown(`the WinSW XML could not be read: ${String(error)}`);
759
744
  }
@@ -801,6 +786,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service
801
786
  home,
802
787
  configDir: deps.configDir,
803
788
  winswStatus: deps.winswStatus,
789
+ windowsLocale: deps.windowsLocale,
804
790
  });
805
791
  }
806
792
  return unknown(`no service manager probe for platform ${platform}`);