@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent, OcxConfig, OcxUsage } from "../../types";
2
+ import { sanitizeLogMetadataString } from "../../lib/redact";
2
3
 
3
4
  /**
4
5
  * Empty-completion guard for Responses turns (port of codex-router's
@@ -21,6 +22,21 @@ import type { AdapterEvent, OcxConfig, OcxUsage } from "../../types";
21
22
  */
22
23
  export const EMPTY_COMPLETION_RETRY_ENV = "OCX_EMPTY_COMPLETION_RETRY";
23
24
 
25
+ /**
26
+ * The observability notice for a turn that ended empty with the retry guard off.
27
+ *
28
+ * Both labels are caller-supplied: the request names its provider and model. Interpolated raw,
29
+ * a model name carrying newlines or terminal escapes writes additional lines into whatever
30
+ * reads this warning, so a caller could forge log records it never produced. Both are reduced
31
+ * to bounded single-line metadata first.
32
+ */
33
+ export function emptyCompletionNotice(providerName: unknown, modelId: unknown): string {
34
+ const provider = sanitizeLogMetadataString(providerName) ?? "unknown";
35
+ const model = sanitizeLogMetadataString(modelId) ?? "unknown";
36
+ return `[opencodex] ${provider}/${model} completed with no output text and no tool call. `
37
+ + "Set \"emptyCompletionRetry\": true to retry such turns once.";
38
+ }
39
+
24
40
  /** Retained pre-content events are bounded independently by count and encoded size. */
25
41
  export const EMPTY_COMPLETION_MAX_BUFFERED_EVENTS = 1_024;
26
42
  export const EMPTY_COMPLETION_MAX_BUFFERED_BYTES = 1_048_576;
@@ -108,6 +108,48 @@ export function providerFetch(
108
108
 
109
109
 
110
110
 
111
+ /**
112
+ * Wrap a provider fetch so `onDispatch` fires immediately before the send, not before pacing.
113
+ *
114
+ * `fetchWithHeaderTimeout` awaits `waitForPacing` and only then calls the executor, so a caller
115
+ * that signals at the call site records a dispatch even when a rejected pacing wait means nothing
116
+ * reached the network. That matters when the signal bounds later recovery: the request would lose
117
+ * its fallback on the strength of a send that never happened.
118
+ *
119
+ * The pacing surface is preserved deliberately. `waitForPacing` and `unpacedFetch` are read off
120
+ * the executor by `fetchWithHeaderTimeout`, so a plain function wrapper would silently drop
121
+ * provider pacing and double-send the slot.
122
+ */
123
+ export function storedPoolReplayDispatchNotifier(
124
+ executor: ProviderFetch,
125
+ onDispatch: (() => void) | undefined,
126
+ ): ProviderFetch {
127
+ if (!onDispatch) return executor;
128
+ let notified = false;
129
+ const notifyOnce = (): void => {
130
+ if (notified) return;
131
+ notified = true;
132
+ onDispatch();
133
+ };
134
+ const unpacedSource = executor.unpacedFetch ?? executor;
135
+ const unpaced = Object.assign(
136
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
137
+ notifyOnce();
138
+ return unpacedSource(input, init);
139
+ },
140
+ { preconnect: unpacedSource.preconnect },
141
+ ) as ProviderFetch["unpacedFetch"];
142
+ const wrapped = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
143
+ await executor.waitForPacing?.(init?.signal ?? undefined);
144
+ return unpaced!(input, init);
145
+ };
146
+ return Object.assign(wrapped, {
147
+ preconnect: executor.preconnect,
148
+ waitForPacing: executor.waitForPacing,
149
+ unpacedFetch: unpaced,
150
+ }) as ProviderFetch;
151
+ }
152
+
111
153
  export async function fetchWithHeaderTimeout(
112
154
  url: string,
113
155
  init: Omit<RequestInit, "signal">,
@@ -116,16 +116,21 @@ export async function handleResponsesWithPolicyFallback(
116
116
  ): Promise<Response> {
117
117
  const runCore = deps.runCore ?? handleResponsesCore;
118
118
  let requestBodyReadNotified = false;
119
- const coreOptions: CoreOptions = options.onRequestBodyRead
120
- ? {
121
- ...options,
119
+ let storedPool401ReplayDispatched = false;
120
+ const coreOptions: CoreOptions = {
121
+ ...options,
122
+ ...(options.onRequestBodyRead ? {
122
123
  onRequestBodyRead: () => {
123
124
  if (requestBodyReadNotified) return;
124
125
  requestBodyReadNotified = true;
125
126
  options.onRequestBodyRead?.();
126
127
  },
127
- }
128
- : options;
128
+ } : {}),
129
+ onStoredPool401ReplayDispatched: () => {
130
+ storedPool401ReplayDispatched = true;
131
+ options.onStoredPool401ReplayDispatched?.();
132
+ },
133
+ };
129
134
  let rawBody: Record<string, unknown> | null = null;
130
135
  try {
131
136
  const parsed = await readJsonRequestBody(req.clone());
@@ -150,7 +155,7 @@ export async function handleResponsesWithPolicyFallback(
150
155
  candidateKey({ provider: initialTrace.selected.provider, model: initialTrace.selected.model }),
151
156
  ]);
152
157
 
153
- while (await shouldHopPolicyCandidate(response, req.signal)) {
158
+ while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
154
159
  if (req.signal.aborted) return response;
155
160
  const next = rankPolicyFallbackCandidates(initialTrace, tried)[0];
156
161
  if (!next) return response;
@@ -1,8 +1,14 @@
1
- import { namespacedToolName, normalizeDeclaredToolName } from "../types";
1
+ import {
2
+ CODE_MODE_EXEC_TOOL_NAME,
3
+ namespacedToolName,
4
+ normalizeDeclaredToolName,
5
+ } from "../types";
2
6
  import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
3
7
 
4
8
  /** Item types the client executes through a request-declared wire name. */
5
9
  const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
10
+ /** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */
11
+ const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
6
12
 
7
13
  /**
8
14
  * Hosted declarations whose response items the PROVIDER executes, keyed by the request
@@ -79,11 +85,18 @@ function addWireToolName(names: Set<string>, tool: unknown, namespace?: string):
79
85
  ? nestedFunction.name
80
86
  : undefined;
81
87
  if (!name) return;
82
- names.add(name);
83
88
  // Codex routes MCP calls by an explicit `namespace` field, so the same tool is reachable
84
89
  // as a bare inner name or as the flattened form; accept both rather than guess which
85
90
  // coordinate system this provider echoes back.
86
- if (namespace) names.add(namespacedToolName(namespace, name));
91
+ if (!namespace || namespace === BUILTIN_FUNCTIONS_NAMESPACE) {
92
+ names.add(name);
93
+ return;
94
+ }
95
+ names.add(namespacedToolName(namespace, name));
96
+ // `exec` is the one name that also switches on nested-helper normalization, so a bare alias
97
+ // for a namespaced MCP tool would silently authorize `exec_command`/`shell_command`/
98
+ // `apply_patch` the request never declared. Every other inner name keeps the bare alias.
99
+ if (name !== CODE_MODE_EXEC_TOOL_NAME) names.add(name);
87
100
  }
88
101
 
89
102
  /**
@@ -9,13 +9,47 @@ import type { OcxConfig } from "../types";
9
9
  import { truncateRetainedUtf8 } from "../lib/admission";
10
10
 
11
11
  const CACHE_TTL_MS = 30_000;
12
- const PROBE_TIMEOUT_MS = 5_000;
13
- const INITIAL_PROBE_WAIT_MS = 5_500;
12
+ const PROBE_TIMEOUT_MS = probeTimeoutMs();
13
+ const INITIAL_PROBE_WAIT_MS = PROBE_TIMEOUT_MS + 500;
14
14
  const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
15
+
16
+ /**
17
+ * How long the isolated probe child gets before its reading is abandoned.
18
+ *
19
+ * The child is a full Bun CLI start that then runs `diagnoseService()`, and on
20
+ * Windows that means shelling out to `sc.exe` / `schtasks.exe` — external
21
+ * processes whose latency is set by the service-control manager, not by us.
22
+ * Under load those overran the flat 5s, the probe was abandoned, and the
23
+ * endpoint answered `diagnosticStale: true` for a machine it could have read.
24
+ * That is a real dashboard regression, not only a test failure: it downgrades a
25
+ * `protected` host to `at-risk` and recommends a repair command for a healthy
26
+ * service.
27
+ *
28
+ * Raising it only on Windows keeps the tighter bound everywhere else. It stays a
29
+ * bound in both cases: a wedged probe is still abandoned, and the caller still
30
+ * receives the previous reading rather than waiting on it.
31
+ */
32
+ function probeTimeoutMs(): number {
33
+ return process.platform === "win32" ? 15_000 : 5_000;
34
+ }
35
+
36
+ /** The probe bound, so a test's own budget cannot fall below what it must wait for. */
37
+ export function startupHealthProbeTimeoutMs(): number {
38
+ return PROBE_TIMEOUT_MS;
39
+ }
15
40
  let cached: { timestamp: number; value: StartupHealth } | null = null;
16
41
  let inflight: Promise<StartupHealth> | null = null;
17
42
  let generation = 0;
18
43
 
44
+ export interface StartupHealthCacheDeps {
45
+ now?: () => number;
46
+ probe?: (config: Pick<OcxConfig, "codexAutoStart">) => Promise<StartupHealth>;
47
+ waitForProbe?: (
48
+ probe: Promise<StartupHealth>,
49
+ timeoutMs: number,
50
+ ) => Promise<StartupHealth | null>;
51
+ }
52
+
19
53
  export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth {
20
54
  if (!value.localRoutingDependency) return { ...value, diagnosticStale: true };
21
55
  return {
@@ -94,11 +128,16 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
94
128
  });
95
129
  }
96
130
 
97
- function refreshInBackground(config: Pick<OcxConfig, "codexAutoStart">): void {
131
+ function refreshInBackground(
132
+ config: Pick<OcxConfig, "codexAutoStart">,
133
+ deps: StartupHealthCacheDeps,
134
+ ): void {
98
135
  if (inflight) return;
99
136
  const startedGeneration = generation;
100
- const probe = runProbe(config).then(value => {
101
- if (startedGeneration === generation) cached = { timestamp: Date.now(), value };
137
+ const probe = (deps.probe ?? runProbe)(config).then(value => {
138
+ if (startedGeneration === generation) {
139
+ cached = { timestamp: (deps.now ?? Date.now)(), value };
140
+ }
102
141
  return value;
103
142
  });
104
143
  inflight = probe.finally(() => {
@@ -107,18 +146,25 @@ function refreshInBackground(config: Pick<OcxConfig, "codexAutoStart">): void {
107
146
  }
108
147
 
109
148
  /** Stale-while-revalidate: service-manager probes never hold open a model/UI request. */
110
- export async function getCachedStartupHealth(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHealth> {
111
- if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.value;
112
- refreshInBackground(config);
149
+ export async function getCachedStartupHealth(
150
+ config: Pick<OcxConfig, "codexAutoStart">,
151
+ deps: StartupHealthCacheDeps = {},
152
+ ): Promise<StartupHealth> {
153
+ const now = deps.now ?? Date.now;
154
+ if (cached && now() - cached.timestamp < CACHE_TTL_MS) return cached.value;
155
+ refreshInBackground(config, deps);
113
156
  // An expired or empty read is an explicit protection check. Wait for the
114
157
  // isolated probe instead of presenting a synthetic failure while that probe
115
158
  // is still running. The probe remains child-process isolated and hard-capped
116
- // at 5s; stale state is returned only if that bounded probe cannot settle.
159
+ // by the platform-specific deadline; stale state is returned only if that
160
+ // bounded probe cannot settle.
117
161
  if (inflight) {
118
- const settled = await Promise.race([
119
- inflight,
120
- new Promise<null>(resolve => setTimeout(() => resolve(null), INITIAL_PROBE_WAIT_MS)),
121
- ]);
162
+ const settled = await (deps.waitForProbe
163
+ ? deps.waitForProbe(inflight, INITIAL_PROBE_WAIT_MS)
164
+ : Promise.race([
165
+ inflight,
166
+ new Promise<null>(resolve => setTimeout(() => resolve(null), INITIAL_PROBE_WAIT_MS)),
167
+ ]));
122
168
  if (settled) return settled;
123
169
  }
124
170
  return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config);
@@ -32,6 +32,19 @@ import { WINSW_SERVICE_ID } from "./lib/winsw";
32
32
  /** Short: this runs inside admission, and a slow answer is the same as none. */
33
33
  export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
34
34
 
35
+ /**
36
+ * The one query that is allowed to be slow: the full `schtasks` listing.
37
+ *
38
+ * 2s is the right budget for a targeted query and the wrong one for enumerating
39
+ * every task on the machine — measured at 12.3s on a host with 401 of them, which
40
+ * killed the listing and left ownership unprovable (#2914). This is not a general
41
+ * relaxation: the targeted queries keep the 2s ceiling, and after the
42
+ * locale-independent absence check above, a healthy host decides before the
43
+ * listing runs at all. Only a host that has already exhausted the cheap evidence
44
+ * pays this, and for it the alternative is not a fast answer but no answer.
45
+ */
46
+ export const SERVICE_PROBE_LISTING_TIMEOUT_MS = 20_000;
47
+
35
48
  export type ServiceManagerBackend = "launchd" | "systemd" | "scheduler" | "winsw";
36
49
 
37
50
  export interface ServiceManagerClaim {
@@ -70,7 +83,11 @@ export interface ProbeRunner {
70
83
  * Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the
71
84
  * UTF-16LE task XML is not corrupted by a UTF-8 decode.
72
85
  */
73
- export type RawProbeRunner = (file: string, args: readonly string[]) => {
86
+ export type RawProbeRunner = (
87
+ file: string,
88
+ args: readonly string[],
89
+ options?: { readonly timeoutMs?: number },
90
+ ) => {
74
91
  status: number | null;
75
92
  stdout: Buffer;
76
93
  stderr: Buffer;
@@ -78,6 +95,57 @@ export type RawProbeRunner = (file: string, args: readonly string[]) => {
78
95
  spawnFailed: boolean;
79
96
  };
80
97
 
98
+ type RawProbeResult = ReturnType<RawProbeRunner>;
99
+
100
+ /**
101
+ * Startup-local memo for the expensive full Task Scheduler listing.
102
+ *
103
+ * The targeted `/tn ... /xml` query is deliberately NOT cached: it is the
104
+ * race-sensitive evidence that a task appeared between two ownership checks.
105
+ * A listing may be reused only when that fresh targeted query returned exactly
106
+ * the same bytes and status as the query that caused the listing. If the
107
+ * targeted evidence changes, the old absence proof is stale and another
108
+ * listing is required rather than turning uncertainty into absence.
109
+ *
110
+ * Only a SUCCESSFUL listing is retained. A stall or spawn failure is not
111
+ * evidence of anything, and caching it made one transient 20s timeout poison the
112
+ * rest of the startup: the targeted query is byte-identical on the next
113
+ * inspection, so the identity check passed, the listing was never retried, and
114
+ * ownership stayed unprovable for the whole run — refusing the write that #2914
115
+ * exists to allow.
116
+ */
117
+ export interface WindowsTaskListingCache {
118
+ getOrRun(targetedQuery: RawProbeResult, run: () => RawProbeResult): RawProbeResult;
119
+ }
120
+
121
+ function rawProbeIdentity(result: RawProbeResult): string {
122
+ return [
123
+ result.status === null ? "null" : String(result.status),
124
+ result.timedOut ? "1" : "0",
125
+ result.spawnFailed ? "1" : "0",
126
+ result.stdout.toString("base64"),
127
+ result.stderr.toString("base64"),
128
+ ].join("\u0000");
129
+ }
130
+
131
+ /** Create one bounded cache for the synchronous ownership phase of one startup. */
132
+ export function createWindowsTaskListingCache(): WindowsTaskListingCache {
133
+ let identity: string | null = null;
134
+ let result: RawProbeResult | null = null;
135
+ return {
136
+ getOrRun(targetedQuery, run) {
137
+ const nextIdentity = rawProbeIdentity(targetedQuery);
138
+ if (result !== null && identity === nextIdentity) return result;
139
+ const next = run();
140
+ if (!next.timedOut && !next.spawnFailed && next.status === 0) {
141
+ identity = nextIdentity;
142
+ result = next;
143
+ }
144
+ return next;
145
+ },
146
+ };
147
+ }
148
+
81
149
  export const defaultProbeRunner: ProbeRunner = (file, args) => {
82
150
  const result = spawnSync(file, [...args], {
83
151
  encoding: "utf8",
@@ -94,11 +162,11 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => {
94
162
  };
95
163
  };
96
164
 
97
- export const defaultRawProbeRunner: RawProbeRunner = (file, args) => {
165
+ export const defaultRawProbeRunner: RawProbeRunner = (file, args, options) => {
98
166
  const result = spawnSync(file, [...args], {
99
167
  encoding: "buffer",
100
168
  windowsHide: true,
101
- timeout: SERVICE_PROBE_TIMEOUT_MS,
169
+ timeout: options?.timeoutMs ?? SERVICE_PROBE_TIMEOUT_MS,
102
170
  });
103
171
  return {
104
172
  status: result.status,
@@ -122,6 +190,8 @@ export interface ProbeDeps {
122
190
  readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown";
123
191
  /** Test seam for redirected Windows legacy-codepage output. */
124
192
  readonly windowsLocale?: string;
193
+ /** Startup-local full-listing cache; targeted task queries always bypass it. */
194
+ readonly windowsTaskListingCache?: WindowsTaskListingCache;
125
195
  }
126
196
 
127
197
  const LABEL = "com.opencodex.proxy";
@@ -323,7 +393,7 @@ function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>)
323
393
  };
324
394
  }
325
395
 
326
- function systemdProperty(out: string, key: string): string | null {
396
+ export function systemdProperty(out: string, key: string): string | null {
327
397
  for (const line of out.split("\n")) {
328
398
  const match = line.match(new RegExp(`^${key}=(.*)$`));
329
399
  if (match) return match[1].trim();
@@ -535,7 +605,22 @@ function windowsTaskListContains(body: string, taskName: string): boolean {
535
605
  });
536
606
  }
537
607
 
538
- /** English hosts provide a decisive fast path; other locales fall back to a full listing. */
608
+ /**
609
+ * The English message: a fast path on an English host, and nothing more.
610
+ *
611
+ * It cannot match a localized host — on zh-CN schtasks answers with the CP936
612
+ * bytes of `错误: 系统找不到指定的文件。` — which is why absence there has to be
613
+ * settled by the locale-neutral listing below (#2914). Adding more translated
614
+ * substrings would only cover the languages someone thought of, and each one is
615
+ * a chance to read a DIFFERENT refusal as absence.
616
+ *
617
+ * Deriving the host's own not-found wording from a control query looks like the
618
+ * general fix and is not: schtasks exits 1 for both "not found" and "access
619
+ * denied", so a locked-down host answers the control and the real query
620
+ * identically, and comparing them yields a false `absent` — the one direction
621
+ * that lets an unattended write proceed into a home another process owns.
622
+ * `tests/codex-service-manager-probe.test.ts` covers exactly that host.
623
+ */
539
624
  const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
540
625
 
541
626
  /**
@@ -547,7 +632,8 @@ const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
547
632
  * fallback, and only a successful list without our task proves absence.
548
633
  */
549
634
  function probeWindowsTaskRegistration(
550
- deps: Required<Pick<ProbeDeps, "runRaw">> & Pick<ProbeDeps, "windowsLocale">,
635
+ deps: Required<Pick<ProbeDeps, "runRaw">>
636
+ & Pick<ProbeDeps, "windowsLocale" | "windowsTaskListingCache">,
551
637
  ): {
552
638
  registered: "present" | "absent" | "unknown";
553
639
  registeredXml: string;
@@ -574,7 +660,14 @@ function probeWindowsTaskRegistration(
574
660
  return { registered: "absent", registeredXml: "" };
575
661
  }
576
662
 
577
- const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]);
663
+ const runListing = () => deps.runRaw(
664
+ schtasks,
665
+ ["/query", "/fo", "CSV", "/nh"],
666
+ { timeoutMs: SERVICE_PROBE_LISTING_TIMEOUT_MS },
667
+ );
668
+ const listed = deps.windowsTaskListingCache
669
+ ? deps.windowsTaskListingCache.getOrRun(queried, runListing)
670
+ : runListing();
578
671
  if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
579
672
  return { registered: "unknown", registeredXml: "" };
580
673
  }
@@ -618,7 +711,7 @@ function probeWinswRegistration(
618
711
 
619
712
  function inspectWindows(
620
713
  deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
621
- & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale">,
714
+ & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale" | "windowsTaskListingCache">,
622
715
  ): ServiceManagerInstallation {
623
716
  const configDir = windowsConfigDirPath(deps);
624
717
  const taskXmlPath = join(configDir, "opencodex-service-task.xml");
@@ -681,7 +774,19 @@ function inspectWindows(
681
774
  }
682
775
 
683
776
  if (task === "absent") {
684
- return schedulerRegistered
777
+ if (!schedulerRegistered) return { kind: "absent" };
778
+ if (!registration.registeredXml.trim()) {
779
+ return unknown("Task Scheduler is registered but its definition XML could not be read");
780
+ }
781
+ const launcherArg = windowsTaskArguments(registration.registeredXml);
782
+ if (!launcherArg) {
783
+ return unknown("Task Scheduler holds opencodex-proxy but its task XML is missing");
784
+ }
785
+ const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1];
786
+ if (!launcherPath) {
787
+ return unknown("Task Scheduler holds opencodex-proxy but its task XML is missing");
788
+ }
789
+ return windowsPathInsideConfigDir(launcherPath, configDir)
685
790
  ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing")
686
791
  : { kind: "absent" };
687
792
  }
@@ -886,6 +991,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service
886
991
  configDir: deps.configDir,
887
992
  winswStatus: deps.winswStatus,
888
993
  windowsLocale: deps.windowsLocale,
994
+ windowsTaskListingCache: deps.windowsTaskListingCache,
889
995
  });
890
996
  }
891
997
  return unknown(`no service manager probe for platform ${platform}`);