@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
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/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DPl4nBMA.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
:
|
|
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 {
|
|
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
|
|
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 =
|
|
13
|
-
const INITIAL_PROBE_WAIT_MS =
|
|
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(
|
|
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)
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
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
|
|
119
|
-
inflight,
|
|
120
|
-
|
|
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 = (
|
|
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
|
-
/**
|
|
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">>
|
|
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
|
|
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
|
|
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}`);
|